1use std::collections::{BTreeMap, BTreeSet};
62
63use ikigai_core::{
64 is_deny_scope, ArgRef, Capability, Error, Invocation, Iri, Request, Result, Verb,
65};
66use oxrdf::{NamedOrBlankNode, Term, Triple};
67use oxttl::TurtleParser;
68use serde::{Deserialize, Serialize};
69
70use crate::authority::Ceiling;
71
72pub const EVAL: &str = "urn:plan:eval";
75pub const VALIDATE: &str = "urn:plan:validate";
77pub const REQUIRES: &str = "urn:plan:requires";
79
80pub const MAX_PLAN_BYTES: usize = 1 << 20;
84
85pub const RESERVED_PARAMETERS: [&str; 4] = ["in", "as", "name", "content"];
89
90const IK: &str = "https://ikigai-rs.dev/ns#";
91const RDF_TYPE: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
92const RDFS_COMMENT: &str = "http://www.w3.org/2000/01/rdf-schema#comment";
93const SH: &str = "http://www.w3.org/ns/shacl#";
94const TURTLE: &str = "text/turtle";
95const OUTCOME: &str = "urn:ikigai:plan:requires:outcome:";
97
98#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
100pub struct Step {
101 pub iri: String,
103 pub verb: String,
105 pub resolves: String,
107}
108
109impl Step {
110 pub fn mutates(&self) -> bool {
112 matches!(self.verb.as_str(), "Sink" | "Delete")
113 }
114}
115
116#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
118pub struct Parameter {
119 pub name: String,
121 pub required: bool,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub default: Option<String>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub class: Option<String>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub summary: Option<String>,
132}
133
134impl Parameter {
135 pub fn must_be_given(&self) -> bool {
137 self.required && self.default.is_none()
138 }
139}
140
141#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
143pub struct Analysis {
144 pub process: String,
146 pub steps: Vec<Step>,
148 pub parameters: Vec<Parameter>,
150}
151
152impl Analysis {
153 pub fn mutates(&self) -> bool {
158 self.steps.iter().any(Step::mutates)
159 }
160}
161
162struct Graph {
168 triples: Vec<Triple>,
169}
170
171fn key(node: &NamedOrBlankNode) -> String {
174 match node {
175 NamedOrBlankNode::NamedNode(n) => n.as_str().to_string(),
176 NamedOrBlankNode::BlankNode(b) => format!("_:{}", b.as_str()),
177 }
178}
179
180fn object_key(term: &Term) -> Option<String> {
181 if let Term::NamedNode(n) = term {
182 return Some(n.as_str().to_string());
183 }
184 if let Term::BlankNode(b) = term {
185 return Some(format!("_:{}", b.as_str()));
186 }
187 None
188}
189
190fn object_literal(term: &Term) -> Option<&str> {
191 if let Term::Literal(l) = term {
192 return Some(l.value());
193 }
194 None
195}
196
197impl Graph {
198 fn parse(text: &str, arg: &str, what: &str) -> Result<Graph> {
199 let triples = TurtleParser::new()
200 .for_slice(text.as_bytes())
201 .collect::<std::result::Result<Vec<_>, _>>()
202 .map_err(|e| Error::InvalidArgument {
203 name: arg.to_string(),
204 detail: format!("{what} is not valid Turtle: {e}"),
205 })?;
206 Ok(Graph { triples })
207 }
208
209 fn objects<'a>(
210 &'a self,
211 subject: &'a str,
212 predicate: &'a str,
213 ) -> impl Iterator<Item = &'a Term> {
214 self.triples
215 .iter()
216 .filter(move |t| key(&t.subject) == subject && t.predicate.as_str() == predicate)
217 .map(|t| &t.object)
218 }
219
220 fn of_class(&self, class: &str) -> BTreeSet<String> {
221 self.triples
222 .iter()
223 .filter(|t| {
224 t.predicate.as_str() == RDF_TYPE && object_key(&t.object).as_deref() == Some(class)
225 })
226 .map(|t| key(&t.subject))
227 .collect()
228 }
229
230 fn with_predicate(&self, predicate: &str) -> BTreeSet<String> {
231 self.triples
232 .iter()
233 .filter(|t| t.predicate.as_str() == predicate)
234 .map(|t| key(&t.subject))
235 .collect()
236 }
237
238 fn literal(&self, subject: &str, predicate: &str, arg: &str) -> Result<Option<String>> {
241 let mut values = self.objects(subject, predicate);
242 let first = values.next();
243 if values.next().is_some() {
244 return Err(Error::InvalidArgument {
245 name: arg.to_string(),
246 detail: format!("<{subject}> has more than one <{predicate}>"),
247 });
248 }
249 match first {
250 None => Ok(None),
251 Some(term) => object_literal(term)
252 .map(|v| Some(v.to_string()))
253 .ok_or_else(|| Error::InvalidArgument {
254 name: arg.to_string(),
255 detail: format!("<{subject}> <{predicate}> is not a literal"),
256 }),
257 }
258 }
259
260 fn iri(&self, subject: &str, predicate: &str, arg: &str) -> Result<Option<String>> {
261 let mut values = self.objects(subject, predicate);
262 let first = values.next();
263 if values.next().is_some() {
264 return Err(Error::InvalidArgument {
265 name: arg.to_string(),
266 detail: format!("<{subject}> has more than one <{predicate}>"),
267 });
268 }
269 match first {
270 None => Ok(None),
271 Some(term) => object_key(term)
272 .map(Some)
273 .ok_or_else(|| Error::InvalidArgument {
274 name: arg.to_string(),
275 detail: format!("<{subject}> <{predicate}> is not a node"),
276 }),
277 }
278 }
279}
280
281pub fn check_bound(source: &str) -> Result<()> {
284 if source.len() > MAX_PLAN_BYTES {
285 return Err(Error::InvalidArgument {
286 name: "content".to_string(),
287 detail: format!(
288 "the plan is {} bytes; a plan is at most {MAX_PLAN_BYTES}",
289 source.len()
290 ),
291 });
292 }
293 Ok(())
294}
295
296fn ik(term: &str) -> String {
297 format!("{IK}{term}")
298}
299
300pub fn analyze(source: &str) -> Result<Analysis> {
306 const ARG: &str = "content";
307 check_bound(source)?;
308 let graph = Graph::parse(source, ARG, "the plan")?;
309 let processes = graph.of_class(&ik("Process"));
310 let process = match processes.len() {
311 1 => processes.into_iter().next().expect("one"),
312 0 => {
313 return Err(Error::InvalidArgument {
314 name: ARG.to_string(),
315 detail: "no ik:Process in this graph: it is not a plan".to_string(),
316 })
317 }
318 n => {
319 return Err(Error::InvalidArgument {
320 name: ARG.to_string(),
321 detail: format!("{n} ik:Process nodes in one graph; a plan holds exactly one"),
322 })
323 }
324 };
325
326 let mut step_iris = graph.of_class(&ik("Step"));
327 step_iris.extend(graph.with_predicate(&ik("verb")));
328 let mut steps = Vec::with_capacity(step_iris.len());
329 for iri in step_iris {
330 let verb =
331 graph
332 .literal(&iri, &ik("verb"), ARG)?
333 .ok_or_else(|| Error::InvalidArgument {
334 name: ARG.to_string(),
335 detail: format!("<{iri}> is a step with no ik:verb"),
336 })?;
337 if !matches!(
338 verb.as_str(),
339 "Source" | "Sink" | "Exists" | "Delete" | "Meta"
340 ) {
341 return Err(Error::InvalidArgument {
342 name: ARG.to_string(),
343 detail: format!(
344 "<{iri}> issues \"{verb}\"; a step issues one of Source, Sink, Exists, \
345 Delete, Meta"
346 ),
347 });
348 }
349 let resolves = graph.iri(&iri, &ik("resolves"), ARG)?.unwrap_or_default();
350 steps.push(Step {
351 iri,
352 verb,
353 resolves,
354 });
355 }
356
357 let mut parameters = Vec::new();
358 for node in graph.objects(&process, &ik("input")) {
359 let Some(node) = object_key(node) else {
360 return Err(Error::InvalidArgument {
361 name: ARG.to_string(),
362 detail: format!("<{process}> ik:input is not a node"),
363 });
364 };
365 let name = graph
366 .literal(&node, &ik("inputName"), ARG)?
367 .ok_or_else(|| Error::InvalidArgument {
368 name: ARG.to_string(),
369 detail: format!("<{node}> is a parameter with no ik:inputName"),
370 })?;
371 if RESERVED_PARAMETERS.contains(&name.as_str()) {
372 return Err(Error::InvalidArgument {
373 name: ARG.to_string(),
374 detail: format!(
375 "the parameter `{name}` would never arrive: {} are taken by the doors \
376 a plan runs through",
377 RESERVED_PARAMETERS.join(", ")
378 ),
379 });
380 }
381 let required = match graph.literal(&node, &ik("required"), ARG)?.as_deref() {
382 None | Some("true") | Some("1") => true,
383 Some("false") | Some("0") => false,
384 Some(other) => {
385 return Err(Error::InvalidArgument {
386 name: ARG.to_string(),
387 detail: format!("<{node}> ik:required \"{other}\" is not a boolean"),
388 })
389 }
390 };
391 parameters.push(Parameter {
392 name,
393 required,
394 default: graph.literal(&node, &ik("default"), ARG)?,
395 class: graph.iri(&node, &ik("class"), ARG)?,
396 summary: graph.literal(&node, &ik("summary"), ARG)?,
397 });
398 }
399 parameters.sort_by(|a, b| a.name.cmp(&b.name));
400 if let Some(pair) = parameters.windows(2).find(|w| w[0].name == w[1].name) {
401 return Err(Error::InvalidArgument {
402 name: ARG.to_string(),
403 detail: format!("the parameter `{}` is declared twice", pair[0].name),
404 });
405 }
406 Ok(Analysis {
407 process,
408 steps,
409 parameters,
410 })
411}
412
413fn door_request(door: &str, plan: &str) -> Request {
418 Request::new(Verb::Source, Iri::parse(door).expect("a constant IRI"))
419 .with_arg("in", ArgRef::Inline(plan.as_bytes().to_vec()))
420 .with_arg("as", ArgRef::Inline(TURTLE.as_bytes().to_vec()))
421}
422
423fn from_door(door: &str, error: Error) -> Error {
426 match error {
427 Error::InvalidArgument { name, detail } if name == "in" => Error::InvalidArgument {
428 name: "content".to_string(),
429 detail: format!("{door}: {detail}"),
430 },
431 Error::Unresolved(_) => Error::InvalidArgument {
432 name: "language".to_string(),
433 detail: format!(
434 "this host takes no plans: {door} does not resolve here (a host offers plans \
435 by binding the plan evaluator's three doors, {EVAL}, {VALIDATE} and \
436 {REQUIRES})"
437 ),
438 },
439 other => other,
440 }
441}
442
443pub async fn validate(inv: &Invocation<'_>, plan: &str) -> Result<()> {
447 let report = inv
448 .issue(door_request(VALIDATE, plan))
449 .await
450 .map_err(|e| from_door(VALIDATE, e))?;
451 let text = String::from_utf8_lossy(&report.bytes);
452 let graph = Graph::parse(&text, "content", &format!("{VALIDATE}'s report"))
453 .map_err(|e| Error::Endpoint(e.to_string()))?;
454 let sh = |term: &str| format!("{SH}{term}");
455 let reports = graph.of_class(&sh("ValidationReport"));
456 let Some(report_node) = reports.iter().next() else {
457 return Err(Error::Endpoint(format!(
458 "{VALIDATE} answered a report with no sh:ValidationReport"
459 )));
460 };
461 let conforms = graph
462 .objects(report_node, &sh("conforms"))
463 .any(|o| object_literal(o) == Some("true"));
464 if conforms {
465 return Ok(());
466 }
467 let mut problems = Vec::new();
468 for result in graph.objects(report_node, &sh("result")) {
469 let Some(node) = object_key(result) else {
470 continue;
471 };
472 let first = |predicate: &str| -> Option<String> {
473 graph.objects(&node, &sh(predicate)).next().map(|o| {
474 object_key(o)
475 .or_else(|| object_literal(o).map(str::to_string))
476 .unwrap_or_default()
477 })
478 };
479 let rule = first("sourceConstraint")
480 .or_else(|| first("sourceShape"))
481 .or_else(|| first("sourceConstraintComponent"))
482 .unwrap_or_else(|| "a SHACL constraint".to_string());
483 let focus = first("focusNode").unwrap_or_default();
484 let message = first("resultMessage").unwrap_or_default();
485 problems.push(format!("{rule} at <{focus}>: {message}"));
486 }
487 problems.sort();
488 Err(Error::InvalidArgument {
489 name: "content".to_string(),
490 detail: format!(
491 "the plan does not validate ({VALIDATE}), so it is not stored: {}",
492 if problems.is_empty() {
493 "the report names no result".to_string()
494 } else {
495 problems.join("; ")
496 }
497 ),
498 })
499}
500
501pub async fn derive(
510 inv: &Invocation<'_>,
511 plan: &str,
512 declared: Option<&str>,
513) -> Result<BTreeSet<String>> {
514 let answer = inv
515 .issue(door_request(REQUIRES, plan))
516 .await
517 .map_err(|e| from_door(REQUIRES, e))?;
518 let text = String::from_utf8_lossy(&answer.bytes);
519 let graph = Graph::parse(&text, "content", &format!("{REQUIRES}'s answer"))
520 .map_err(|e| Error::Endpoint(e.to_string()))?;
521 let processes = graph.of_class(&ik("Process"));
522 let [process] = processes.iter().collect::<Vec<_>>()[..] else {
523 return Err(Error::Endpoint(format!(
524 "{REQUIRES} answered {} ik:Process nodes; it describes exactly one plan",
525 processes.len()
526 )));
527 };
528 let outcome = graph.iri(process, &ik("outcome"), "content")?;
529 if outcome.as_deref() != Some(&format!("{OUTCOME}complete")) {
530 let mut unresolved = Vec::new();
531 for step in graph.of_class(&ik("Step")) {
532 let resolved = graph.iri(&step, &ik("outcome"), "content")?;
533 if resolved.as_deref() == Some(&format!("{OUTCOME}resolved")) {
534 continue;
535 }
536 let reasons: Vec<String> = graph
537 .objects(&step, RDFS_COMMENT)
538 .filter_map(object_literal)
539 .map(str::to_string)
540 .collect();
541 unresolved.push(format!(
542 "<{step}> ({})",
543 if reasons.is_empty() {
544 "no reason given".to_string()
545 } else {
546 reasons.join("; ")
547 }
548 ));
549 }
550 return Err(Error::InvalidArgument {
551 name: "content".to_string(),
552 detail: format!(
553 "the plan's authority cannot be derived, so it is not stored: {REQUIRES} could \
554 not read every step's contract, and a partial set would be a floor, not the \
555 requirement. Unresolved: {}",
556 if unresolved.is_empty() {
557 format!("{REQUIRES} answered {outcome:?} and named no step")
558 } else {
559 unresolved.join(", ")
560 }
561 ),
562 });
563 }
564 let derived: BTreeSet<String> = graph
565 .objects(process, &ik("requires"))
566 .filter_map(object_key)
567 .collect();
568 if let Some(bad) = derived.iter().find(|s| is_deny_scope(s)) {
569 return Err(Error::Endpoint(format!(
570 "{REQUIRES} derived the exclusion `{bad}`, which is not a requirement"
571 )));
572 }
573 if let Some(said) = declared {
574 let said: BTreeSet<String> = said.split_whitespace().map(str::to_string).collect();
575 if said != derived {
576 let listed = |set: &BTreeSet<String>| {
577 if set.is_empty() {
578 "nothing".to_string()
579 } else {
580 set.iter().cloned().collect::<Vec<_>>().join(" ")
581 }
582 };
583 return Err(Error::InvalidArgument {
584 name: "requires".to_string(),
585 detail: format!(
586 "a plan's authority is derived from its steps' contracts, not declared, \
587 and this one derives {}; the `requires` given says {}. Omit `requires`",
588 listed(&derived),
589 listed(&said)
590 ),
591 });
592 }
593 }
594 Ok(derived)
595}
596
597pub fn expand_families(
617 keep: BTreeSet<String>,
618 runner: &Capability,
619 ceiling: &Ceiling,
620) -> BTreeSet<String> {
621 let mut out = BTreeSet::new();
622 for scope in keep {
623 let Some(prefix) = scope.strip_suffix('*').filter(|_| !is_deny_scope(&scope)) else {
624 out.insert(scope);
625 continue;
626 };
627 match runner.scopes() {
628 Some(held) => out.extend(
629 held.iter()
630 .filter(|s| s.starts_with(prefix) && !s.ends_with('*') && !is_deny_scope(s))
631 .filter(|s| ceiling.allows(s))
632 .cloned(),
633 ),
634 None => {
635 if let Ceiling::Scopes(named) = ceiling {
636 out.extend(
637 named
638 .iter()
639 .filter(|s| {
640 s.starts_with(prefix) && !s.ends_with('*') && !is_deny_scope(s)
641 })
642 .cloned(),
643 );
644 }
645 if ceiling.allows(&scope) {
646 out.insert(scope.clone());
647 }
648 }
649 }
650 }
651 out
652}
653
654pub fn eval_request(
657 program: &str,
658 values: &BTreeMap<String, String>,
659 face: Option<&str>,
660) -> Request {
661 let mut request = Request::new(Verb::Source, Iri::parse(EVAL).expect("a constant IRI"))
662 .with_arg("in", ArgRef::Inline(program.as_bytes().to_vec()));
663 for (name, value) in values {
664 request = request.with_arg(name.clone(), ArgRef::Inline(value.as_bytes().to_vec()));
665 }
666 if let Some(face) = face {
667 request = request.with_arg("as", ArgRef::Inline(face.as_bytes().to_vec()));
668 }
669 request
670}
671
672#[cfg(test)]
673mod tests {
674 use super::*;
675
676 fn set(xs: &[&str]) -> BTreeSet<String> {
677 xs.iter().map(|s| s.to_string()).collect()
678 }
679
680 const TWO_STEPS: &str = r#"
681@prefix ik: <https://ikigai-rs.dev/ns#> .
682<urn:plan:p> a ik:Process ; ik:step <urn:plan:p:step:1> , <urn:plan:p:step:2> ;
683 ik:result <urn:plan:p:step:2> .
684<urn:plan:p:step:1> a ik:Step ; ik:verb "Source" ; ik:resolves <urn:a> .
685<urn:plan:p:step:2> ik:verb "Sink" ; ik:resolves <urn:b> ; ik:pipeFrom <urn:plan:p:step:1> .
686"#;
687
688 #[test]
689 fn a_sink_anywhere_makes_a_write_even_untyped() {
690 let analysis = analyze(TWO_STEPS).unwrap();
691 assert_eq!(analysis.steps.len(), 2);
692 assert!(analysis.mutates());
693 let read = TWO_STEPS.replace("\"Sink\"", "\"Exists\"");
694 assert!(!analyze(&read).unwrap().mutates());
695 }
696
697 #[test]
698 fn the_read_refuses_what_it_cannot_classify() {
699 for (plan, says) in [
700 ("not turtle at all {", "not valid Turtle"),
701 ("<urn:x> <urn:y> <urn:z> .", "no ik:Process"),
702 (
703 &TWO_STEPS.replace("\"Sink\"", "\"Launch\"") as &str,
704 "Launch",
705 ),
706 (
707 &TWO_STEPS.replace("ik:verb \"Sink\"", "ik:verb \"Sink\" , \"Source\""),
708 "more than one",
709 ),
710 ] {
711 match analyze(plan) {
712 Err(Error::InvalidArgument { name, detail }) => {
713 assert_eq!(name, "content");
714 assert!(detail.contains(says), "{says}: {detail}");
715 }
716 other => panic!("{says}: {other:?}"),
717 }
718 }
719 let huge = format!("{TWO_STEPS}#{}", "x".repeat(MAX_PLAN_BYTES));
720 assert!(analyze(&huge).is_err());
721 }
722
723 #[test]
724 fn a_reserved_parameter_is_refused() {
725 let plan = format!(
726 "{TWO_STEPS}<urn:plan:p> ik:input <urn:plan:p:input:in> .\n\
727 <urn:plan:p:input:in> ik:inputName \"in\" .\n"
728 );
729 assert!(matches!(analyze(&plan), Err(Error::InvalidArgument { .. })));
730 }
731
732 #[test]
733 fn a_root_publishers_family_becomes_the_runners_members() {
734 let keep = set(&["urn:cap:a", "urn:cap:net:*"]);
735 let runner = Capability::scoped([
736 "urn:cap:a",
737 "urn:cap:net:example.com",
738 "urn:cap:net:other.org",
739 "urn:cap:net:-example.com/admin",
740 ]);
741 let ceiling = Ceiling::scoped(["urn:cap:a", "urn:cap:net:example.com"]);
742 assert_eq!(
743 expand_families(keep.clone(), &runner, &ceiling),
744 set(&["urn:cap:a", "urn:cap:net:example.com"])
745 );
746 assert_eq!(
748 expand_families(keep.clone(), &Capability::root(), &ceiling),
749 set(&["urn:cap:a", "urn:cap:net:example.com"])
750 );
751 assert_eq!(
754 expand_families(keep.clone(), &Capability::root(), &Ceiling::Unbounded),
755 set(&["urn:cap:a", "urn:cap:net:*"])
756 );
757 assert_eq!(
759 expand_families(keep, &Capability::root(), &Ceiling::scoped(["urn:cap:a"])),
760 set(&["urn:cap:a"])
761 );
762 }
763}