use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::ids::{CardName, SlotName};
use crate::verification::TaskId;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reference {
pub root: ReferenceRoot,
pub path: Vec<PathSegment>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReferenceRoot {
Input(SlotName),
StepOutput(TaskId),
StepStatus(TaskId),
StepVerdict {
step: TaskId,
requirement: TaskId,
field: VerdictField,
},
RequirementId,
RequirementStatement,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VerdictField {
Outcome,
Reason,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathSegment {
Field(String),
Index(usize),
Wildcard,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("invalid reference `{reference}`: {reason}")]
pub struct ReferenceError {
pub reference: String,
pub reason: &'static str,
}
impl Reference {
#[must_use]
pub fn step(&self) -> Option<&TaskId> {
match &self.root {
ReferenceRoot::StepOutput(step)
| ReferenceRoot::StepStatus(step)
| ReferenceRoot::StepVerdict { step, .. } => Some(step),
ReferenceRoot::Input(_)
| ReferenceRoot::RequirementId
| ReferenceRoot::RequirementStatement => None,
}
}
#[must_use]
pub fn slot(&self) -> Option<&SlotName> {
match &self.root {
ReferenceRoot::Input(slot) => Some(slot),
_ => None,
}
}
#[must_use]
pub fn has_wildcard(&self) -> bool {
self.path.contains(&PathSegment::Wildcard)
}
pub fn parse(text: &str) -> Result<Self, ReferenceError> {
let fail = |reason| ReferenceError {
reference: text.to_owned(),
reason,
};
let mut tokens = Tokens::new(text);
let head = tokens.name().ok_or_else(|| fail("expected a root name"))?;
let root = match head {
"inputs" => {
let slot = tokens
.dotted()
.ok_or_else(|| fail("expected inputs.<slot>"))?;
ReferenceRoot::Input(SlotName::new(slot).map_err(|_| fail("invalid slot name"))?)
}
"steps" => {
let step = tokens.dotted().ok_or_else(|| fail("expected steps.<id>"))?;
let step = TaskId::new(step).map_err(|_| fail("invalid step id"))?;
match tokens.dotted() {
Some("output") => ReferenceRoot::StepOutput(step),
Some("status") => ReferenceRoot::StepStatus(step),
Some("requirements") => {
let requirement = tokens
.dotted()
.ok_or_else(|| fail("expected requirements.<id>"))?;
let requirement =
TaskId::new(requirement).map_err(|_| fail("invalid requirement id"))?;
let field = match tokens.dotted() {
Some("outcome") => VerdictField::Outcome,
Some("reason") => VerdictField::Reason,
_ => return Err(fail("expected .outcome or .reason")),
};
ReferenceRoot::StepVerdict {
step,
requirement,
field,
}
}
_ => return Err(fail("expected .output, .status, or .requirements")),
}
}
"requirement" => match tokens.dotted() {
Some("id") => ReferenceRoot::RequirementId,
Some("statement") => ReferenceRoot::RequirementStatement,
_ => return Err(fail("expected requirement.id or requirement.statement")),
},
_ => return Err(fail("root must be inputs, steps, or requirement")),
};
let path = tokens
.segments()
.ok_or_else(|| fail("malformed selector"))?;
if !path.is_empty()
&& !matches!(root, ReferenceRoot::Input(_) | ReferenceRoot::StepOutput(_))
{
return Err(fail("only inputs and step outputs accept child selectors"));
}
Ok(Self { root, path })
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SourceText(
String,
);
impl SourceText {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
pub fn parse(&self) -> Result<SourceReference, ReferenceError> {
SourceReference::parse(&self.0)
}
}
impl schemars::JsonSchema for SourceText {
fn schema_name() -> String {
"SourceText".to_owned()
}
fn json_schema(_generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
use schemars::schema::{InstanceType, Metadata, SchemaObject};
SchemaObject {
metadata: Some(Box::new(Metadata {
description: Some(SOURCE_DESCRIPTION.to_owned()),
..Metadata::default()
})),
instance_type: Some(InstanceType::String.into()),
..SchemaObject::default()
}
.into()
}
}
#[cfg(feature = "server")]
impl utoipa::PartialSchema for SourceText {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
utoipa::openapi::schema::ObjectBuilder::new()
.schema_type(utoipa::openapi::schema::Type::String)
.description(Some(SOURCE_DESCRIPTION))
.into()
}
}
#[cfg(feature = "server")]
impl utoipa::ToSchema for SourceText {}
const SOURCE_DESCRIPTION: &str = "Group source reference: verifiers.<name>.inputs.<slot> or \
verifiers.<name>.requirements.<requirement>.outcome, selecting one whole slot or \
requirement outcome per source execution.";
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct SourceReference {
pub verifier: CardName,
pub field: SourceField,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum SourceField {
Input(SlotName),
Outcome(TaskId),
}
impl SourceReference {
pub fn parse(text: &str) -> Result<Self, ReferenceError> {
let fail = |reason| ReferenceError {
reference: text.to_owned(),
reason,
};
let mut tokens = Tokens::new(text);
if tokens.name() != Some("verifiers") {
return Err(fail(
"a source must be verifiers.<name>.inputs.<slot> or verifiers.<name>.requirements.<id>.outcome",
));
}
let verifier = tokens
.dotted()
.and_then(|name| CardName::new(name).ok())
.ok_or_else(|| fail("expected verifiers.<name> naming a Verifier Card"))?;
let field = match tokens.dotted() {
Some("inputs") => SourceField::Input(
tokens
.dotted()
.and_then(|slot| SlotName::new(slot).ok())
.ok_or_else(|| fail("expected inputs.<slot>"))?,
),
Some("requirements") => {
let requirement = tokens
.dotted()
.and_then(|id| TaskId::new(id).ok())
.ok_or_else(|| fail("expected requirements.<id>"))?;
match tokens.dotted() {
Some("outcome") => SourceField::Outcome(requirement),
Some("reason") => {
return Err(fail(
"a source selects a requirement outcome only; .reason is not selectable",
));
}
_ => return Err(fail("expected requirements.<id>.outcome")),
}
}
_ => {
return Err(fail(
"expected .inputs.<slot> or .requirements.<id>.outcome",
));
}
};
if !tokens.rest.is_empty() {
return Err(fail(
"a source selects a whole slot or outcome; child fields, indexes and wildcards are refused",
));
}
Ok(Self { verifier, field })
}
}
impl fmt::Display for SourceReference {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.field {
SourceField::Input(slot) => write!(f, "verifiers.{}.inputs.{slot}", self.verifier),
SourceField::Outcome(requirement) => write!(
f,
"verifiers.{}.requirements.{}.outcome",
self.verifier,
requirement.as_str()
),
}
}
}
struct Tokens<'a> {
rest: &'a str,
}
impl<'a> Tokens<'a> {
fn new(text: &'a str) -> Self {
Self { rest: text }
}
fn name(&mut self) -> Option<&'a str> {
let end = self
.rest
.find(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'))
.unwrap_or(self.rest.len());
if end == 0 {
return None;
}
let (name, rest) = self.rest.split_at(end);
self.rest = rest;
Some(name)
}
fn dotted(&mut self) -> Option<&'a str> {
self.rest = self.rest.strip_prefix('.')?;
self.name()
}
fn segments(&mut self) -> Option<Vec<PathSegment>> {
let mut segments = Vec::new();
while !self.rest.is_empty() {
if self.rest.starts_with('.') {
segments.push(PathSegment::Field(self.dotted()?.to_owned()));
} else {
let (inner, rest) = self.rest.strip_prefix('[')?.split_once(']')?;
self.rest = rest;
segments.push(if inner == "*" {
PathSegment::Wildcard
} else if !inner.is_empty() && inner.bytes().all(|byte| byte.is_ascii_digit()) {
PathSegment::Index(inner.parse().ok()?)
} else {
return None;
});
}
}
Some(segments)
}
}
impl fmt::Display for Reference {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.root {
ReferenceRoot::Input(slot) => write!(f, "inputs.{slot}")?,
ReferenceRoot::StepOutput(step) => write!(f, "steps.{}.output", step.as_str())?,
ReferenceRoot::StepStatus(step) => write!(f, "steps.{}.status", step.as_str())?,
ReferenceRoot::StepVerdict {
step,
requirement,
field,
} => {
let field = match field {
VerdictField::Outcome => "outcome",
VerdictField::Reason => "reason",
};
write!(
f,
"steps.{}.requirements.{}.{field}",
step.as_str(),
requirement.as_str()
)?;
}
ReferenceRoot::RequirementId => f.write_str("requirement.id")?,
ReferenceRoot::RequirementStatement => f.write_str("requirement.statement")?,
}
for segment in &self.path {
match segment {
PathSegment::Field(name) => write!(f, ".{name}")?,
PathSegment::Index(index) => write!(f, "[{index}]")?,
PathSegment::Wildcard => f.write_str("[*]")?,
}
}
Ok(())
}
}
impl FromStr for Reference {
type Err = ReferenceError;
fn from_str(text: &str) -> Result<Self, Self::Err> {
Self::parse(text)
}
}
impl Serialize for Reference {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for Reference {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let text = String::deserialize(deserializer)?;
Self::parse(&text).map_err(serde::de::Error::custom)
}
}
impl schemars::JsonSchema for Reference {
fn schema_name() -> String {
"Reference".to_owned()
}
fn json_schema(_generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
use schemars::schema::{InstanceType, Metadata, SchemaObject};
SchemaObject {
metadata: Some(Box::new(Metadata {
description: Some(
"Scope reference: inputs.<slot>, steps.<id>.output, steps.<id>.status, \
steps.<id>.requirements.<id>.outcome|reason, requirement.id, or \
requirement.statement, with .field, [n], and (in assert each) [*] \
selectors below inputs and step outputs."
.to_owned(),
),
..Metadata::default()
})),
instance_type: Some(InstanceType::String.into()),
..SchemaObject::default()
}
.into()
}
}
#[cfg(feature = "server")]
impl utoipa::PartialSchema for Reference {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
utoipa::openapi::schema::ObjectBuilder::new()
.schema_type(utoipa::openapi::schema::Type::String)
.description(Some(
"Scope reference: inputs.<slot>, steps.<id>.output, steps.<id>.status, \
steps.<id>.requirements.<id>.outcome|reason, requirement.id, or \
requirement.statement, with .field, [n], and (in assert each) [*] \
selectors below inputs and step outputs.",
))
.into()
}
}
#[cfg(feature = "server")]
impl utoipa::ToSchema for Reference {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn source_reference_accepts_only_whole_slots_and_outcomes() {
for text in [
"verifiers.turn-verifier.inputs.turn",
"verifiers.refund-turn.requirements.valid-fact.outcome",
] {
let parsed = SourceReference::parse(text).expect(text);
assert_eq!(parsed.to_string(), text);
}
for text in [
"verifiers.turn-verifier.inputs.turn.text",
"verifiers.turn-verifier.inputs.turn[0]",
"verifiers.turn-verifier.inputs.turn[*]",
"verifiers.refund-turn.requirements.valid-fact.reason",
"verifiers.refund-turn.requirements.valid-fact.outcome.x",
"verifiers.refund-turn.steps.a.output",
"verifiers.X.inputs.turn",
"inputs.turn",
] {
assert!(SourceReference::parse(text).is_err(), "{text}");
}
}
#[test]
fn reference_round_trips_every_root() {
for text in [
"inputs.answer",
"inputs.reply.refund_amount",
"steps.structure.output.slides[*].title",
"steps.workbook.output.sheets[0].cells[1].value",
"steps.scope.status",
"steps.scope.requirements.reviewable-size.outcome",
"steps.scope.requirements.reviewable-size.reason",
"requirement.id",
"requirement.statement",
] {
let reference = Reference::parse(text).expect(text);
assert_eq!(reference.to_string(), text);
}
let reference = Reference::parse("steps.a.output.x[2]").expect("parse");
assert_eq!(reference.step().map(TaskId::as_str), Some("a"));
assert_eq!(
reference.path,
vec![PathSegment::Field("x".to_owned()), PathSegment::Index(2)]
);
}
#[test]
fn reference_refuses_malformed_text() {
for text in [
"",
"$.inputs.a",
"output",
"inputs",
"inputs.a-b",
"steps.a",
"steps.a.result",
"steps.a.requirements.b",
"steps.a.requirements.b.verdict",
"steps.a.status.x",
"steps.a.requirements.b.outcome.x",
"requirement.statement.x",
"requirement.other",
"inputs.a.",
"inputs.a[]",
"inputs.a[x]",
"inputs.a[-1]",
"inputs.a[0",
"inputs.a..b",
"inputs.a b",
] {
assert!(Reference::parse(text).is_err(), "{text}");
}
}
#[test]
fn reference_serde_uses_string_form() {
let reference: Reference =
serde_json::from_str("\"inputs.reply.text\"").expect("deserialize");
assert_eq!(
serde_json::to_value(&reference).expect("serialize"),
"inputs.reply.text"
);
assert!(serde_json::from_str::<Reference>("\"steps.x\"").is_err());
}
}