use std::fmt;
use thiserror::Error;
use crate::ids::{CaptureName, CorpusKey, IdError, RecipeName, ViewName};
use crate::vocab::OutcomeKind;
#[derive(Debug, Error)]
pub enum RefError {
#[error("unterminated ${{…}} reference in {0:?}")]
Unterminated(String),
#[error("illegal reference ${{{0}}}: {1}")]
Illegal(String, String),
#[error("in ${{{reference}}}: {source}")]
BadIdent {
reference: String,
source: IdError,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FixtureField {
DataSet,
Expected,
Defect,
}
impl FixtureField {
fn parse(s: &str) -> Option<Self> {
match s {
"data_set" => Some(Self::DataSet),
"expected" => Some(Self::Expected),
"defect" => Some(Self::Defect),
_ => None,
}
}
#[must_use]
pub fn token(self) -> &'static str {
match self {
Self::DataSet => "data_set",
Self::Expected => "expected",
Self::Defect => "defect",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IxitField {
SystemId,
DumpLocation,
}
impl IxitField {
fn parse(s: &str) -> Option<Self> {
match s {
"system_id" => Some(Self::SystemId),
"dump_location" => Some(Self::DumpLocation),
_ => None,
}
}
#[must_use]
pub fn token(self) -> &'static str {
match self {
Self::SystemId => "system_id",
Self::DumpLocation => "dump_location",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TimeExpr {
Before(CaptureName),
After(CaptureName),
Between(CaptureName, CaptureName),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValueRef {
Row(String),
Fixture(FixtureField),
Capture {
name: CaptureName,
optional: bool,
},
DataSet {
key: CorpusKey,
view: Option<ViewName>,
},
FixtureDataSet,
Recipe(RecipeName),
Ixit(IxitField),
Time(TimeExpr),
}
impl ValueRef {
pub fn parse(body: &str) -> Result<Self, RefError> {
let bad_ident = |source| RefError::BadIdent {
reference: body.to_owned(),
source,
};
let illegal = |why: &str| RefError::Illegal(body.to_owned(), why.to_owned());
if let Some(column) = body.strip_prefix("row.") {
if column.is_empty()
|| column.contains(char::is_whitespace)
|| column.contains(['{', '}'])
{
return Err(illegal("row column must be a non-empty brace-free name"));
}
return Ok(Self::Row(column.to_owned()));
}
if let Some(field) = body.strip_prefix("fixture.") {
return FixtureField::parse(field)
.map(Self::Fixture)
.ok_or_else(|| illegal("fixture field must be data_set | expected | defect"));
}
if body == "ds:fixture" {
return Ok(Self::FixtureDataSet);
}
if let Some(rest) = body.strip_prefix("ds:") {
let (key, view) = match rest.split_once('#') {
Some((key, view)) => (key, Some(ViewName::parse(view).map_err(bad_ident)?)),
None => (rest, None),
};
let key = CorpusKey::parse(key).map_err(bad_ident)?;
return Ok(Self::DataSet { key, view });
}
if let Some(rest) = body.strip_prefix("recipe:") {
let name = rest
.strip_suffix("(row)")
.ok_or_else(|| illegal("recipe reference must end in (row)"))?;
return Ok(Self::Recipe(RecipeName::parse(name).map_err(bad_ident)?));
}
if let Some(rest) = body.strip_prefix("time:") {
return Self::parse_time(rest).map(Self::Time).ok_or_else(|| {
illegal("time expression must be before(<t>) | after(<t>) | between(<t1>,<t2>)")
});
}
if let Some(field) = body.strip_prefix("ixit:") {
return IxitField::parse(field)
.map(Self::Ixit)
.ok_or_else(|| illegal("ixit field must be system_id | dump_location"));
}
if body.contains(':') || body.contains('.') {
return Err(illegal(
"unknown reference form (closed grammar: row./fixture./ds:/recipe:/time:/ixit:/<capture>)",
));
}
let (name, optional) = match body.strip_suffix('?') {
Some(name) => (name, true),
None => (body, false),
};
Ok(Self::Capture {
name: CaptureName::parse(name).map_err(bad_ident)?,
optional,
})
}
fn parse_time(rest: &str) -> Option<TimeExpr> {
let inner = |prefix: &str| -> Option<&str> {
rest.strip_prefix(prefix)?
.strip_prefix('(')?
.strip_suffix(')')
};
if let Some(arg) = inner("before") {
return CaptureName::parse(arg.trim()).ok().map(TimeExpr::Before);
}
if let Some(arg) = inner("after") {
return CaptureName::parse(arg.trim()).ok().map(TimeExpr::After);
}
if let Some(args) = inner("between") {
let (a, b) = args.split_once(',')?;
return Some(TimeExpr::Between(
CaptureName::parse(a.trim()).ok()?,
CaptureName::parse(b.trim()).ok()?,
));
}
None
}
}
impl fmt::Display for ValueRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Row(c) => write!(f, "${{row.{c}}}"),
Self::Fixture(field) => write!(f, "${{fixture.{}}}", field.token()),
Self::Capture { name, optional } => {
write!(f, "${{{name}{}}}", if *optional { "?" } else { "" })
}
Self::DataSet {
key,
view: Some(view),
} => write!(f, "${{ds:{key}#{view}}}"),
Self::DataSet { key, view: None } => write!(f, "${{ds:{key}}}"),
Self::FixtureDataSet => f.write_str("${ds:fixture}"),
Self::Recipe(name) => write!(f, "${{recipe:{name}(row)}}"),
Self::Ixit(field) => write!(f, "${{ixit:{}}}", field.token()),
Self::Time(TimeExpr::Before(t)) => write!(f, "${{time:before({t})}}"),
Self::Time(TimeExpr::After(t)) => write!(f, "${{time:after({t})}}"),
Self::Time(TimeExpr::Between(a, b)) => write!(f, "${{time:between({a},{b})}}"),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Segment {
Lit(String),
Ref(ValueRef),
}
#[derive(Debug, Clone, PartialEq)]
pub struct Template {
raw: String,
segments: Vec<Segment>,
}
impl Template {
pub fn parse(raw: &str) -> Result<Self, RefError> {
let mut segments = Vec::new();
let mut rest = raw;
while let Some(start) = rest.find("${") {
let (lit, tail) = rest.split_at(start);
if !lit.is_empty() {
segments.push(Segment::Lit(lit.to_owned()));
}
let body_and_more = tail.get(2..).unwrap_or_default();
let end = body_and_more
.find('}')
.ok_or_else(|| RefError::Unterminated(raw.to_owned()))?;
let body = body_and_more.get(..end).unwrap_or_default();
segments.push(Segment::Ref(ValueRef::parse(body)?));
rest = body_and_more.get(end + 1..).unwrap_or_default();
}
if !rest.is_empty() {
segments.push(Segment::Lit(rest.to_owned()));
}
Ok(Self {
raw: raw.to_owned(),
segments,
})
}
#[must_use]
pub fn raw(&self) -> &str {
&self.raw
}
#[must_use]
pub fn segments(&self) -> &[Segment] {
&self.segments
}
pub fn refs(&self) -> impl Iterator<Item = &ValueRef> {
self.segments.iter().filter_map(|s| match s {
Segment::Ref(r) => Some(r),
Segment::Lit(_) => None,
})
}
#[must_use]
pub fn as_single_ref(&self) -> Option<&ValueRef> {
match self.segments.as_slice() {
[Segment::Ref(r)] => Some(r),
_ => None,
}
}
}
impl fmt::Display for Template {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.raw)
}
}
impl serde::Serialize for Template {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.raw)
}
}
impl<'de> serde::Deserialize<'de> for Template {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Self::parse(&s).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CaptureField {
Body,
CommitTime,
Field {
name: CaptureName,
list: bool,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CaptureValueSource {
pub outcome: OutcomeKind,
pub field: CaptureField,
}
impl CaptureValueSource {
pub fn parse(raw: &str) -> Result<Self, RefError> {
let illegal = |why: &str| RefError::Illegal(raw.to_owned(), why.to_owned());
let (outcome, field) = raw
.split_once('.')
.ok_or_else(|| illegal("capture source must be <outcome>.<field>"))?;
let outcome = OutcomeKind::from_token(outcome)
.ok_or_else(|| illegal("capture source outcome must be an outcome kind"))?;
let field = match field {
"body" => CaptureField::Body,
"commit_time" => CaptureField::CommitTime,
other => {
let (name, list) = match other.strip_suffix("[]") {
Some(name) => (name, true),
None => (other, false),
};
CaptureField::Field {
name: CaptureName::parse(name).map_err(|source| RefError::BadIdent {
reference: raw.to_owned(),
source,
})?,
list,
}
}
};
Ok(Self { outcome, field })
}
}
impl fmt::Display for CaptureValueSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let token = self.outcome.token();
match &self.field {
CaptureField::Body => write!(f, "{token}.body"),
CaptureField::CommitTime => write!(f, "{token}.commit_time"),
CaptureField::Field { name, list } => {
write!(f, "{token}.{name}{}", if *list { "[]" } else { "" })
}
}
}
}
impl serde::Serialize for CaptureValueSource {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}
impl<'de> serde::Deserialize<'de> for CaptureValueSource {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Self::parse(&s).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
#[expect(
clippy::panic_in_result_fn,
reason = "Result-returning tests in the Book ch11 shape, each asserting; \
clippy offers no allow-in-tests knob for this lint"
)]
mod tests {
use super::*;
fn parse_ref(body: &str) -> ValueRef {
ValueRef::parse(body).unwrap()
}
#[test]
fn row_column_refuses_braces() {
assert!(ValueRef::parse("row.a}b").is_err());
assert!(ValueRef::parse("row.a{b").is_err());
}
#[test]
fn closed_forms_parse() {
assert_eq!(parse_ref("row.ehr_id"), ValueRef::Row("ehr_id".into()));
assert_eq!(
parse_ref("fixture.expected"),
ValueRef::Fixture(FixtureField::Expected)
);
assert!(matches!(
parse_ref("first_ehr_id"),
ValueRef::Capture {
optional: false,
..
}
));
assert!(matches!(
parse_ref("offset?"),
ValueRef::Capture { optional: true, .. }
));
assert!(matches!(
parse_ref("ds:cnf.set.bp-10"),
ValueRef::DataSet { view: None, .. }
));
assert!(matches!(
parse_ref("ds:cnf.set.bp-10#magnitude_ge_140_by_uid"),
ValueRef::DataSet { view: Some(_), .. }
));
assert!(matches!(
parse_ref("recipe:ehr_status(row)"),
ValueRef::Recipe(_)
));
assert!(matches!(
parse_ref("time:before(t1)"),
ValueRef::Time(TimeExpr::Before(_))
));
assert!(matches!(
parse_ref("time:between(t1,t2)"),
ValueRef::Time(TimeExpr::Between(..))
));
assert_eq!(
parse_ref("ixit:system_id"),
ValueRef::Ixit(IxitField::SystemId)
);
}
#[test]
fn ixit_references_round_trip_and_stay_closed() {
let r = parse_ref("ixit:system_id");
assert_eq!(r.to_string(), "${ixit:system_id}");
let template = Template::parse(&r.to_string()).unwrap();
assert_eq!(template.as_single_ref(), Some(&r));
assert!(ValueRef::parse("ixit:hardware_class").is_err());
assert!(ValueRef::parse("ixit:").is_err());
}
#[test]
fn illegal_forms_rejected() {
assert!(ValueRef::parse("step2.body").is_err()); assert!(ValueRef::parse("fixture.payload").is_err());
assert!(ValueRef::parse("recipe:ehr_status").is_err());
assert!(ValueRef::parse("time:around(t1)").is_err());
assert!(ValueRef::parse("ds:Not.A.Key").is_err());
}
#[test]
fn templates_scan_all_refs() {
let t = Template::parse("${versioned_object_uid}::<system>::2").unwrap();
assert_eq!(t.refs().count(), 1);
assert!(t.as_single_ref().is_none());
assert!(Template::parse("${unclosed").is_err());
assert!(Template::parse("prefix ${step2.body} suffix").is_err());
let single = Template::parse("${ds:cnf.composition.minimal_event.v1}").unwrap();
assert!(single.as_single_ref().is_some());
}
#[test]
fn capture_sources() {
let s = CaptureValueSource::parse("created.version_uids[]").unwrap();
assert_eq!(s.outcome, OutcomeKind::Created);
assert!(matches!(s.field, CaptureField::Field { list: true, .. }));
assert!(matches!(
CaptureValueSource::parse("ok.body").unwrap().field,
CaptureField::Body
));
assert!(matches!(
CaptureValueSource::parse("created.commit_time")
.unwrap()
.field,
CaptureField::CommitTime
));
assert!(CaptureValueSource::parse("nonsense.ehr_id").is_err());
assert!(CaptureValueSource::parse("created").is_err());
}
#[test]
fn every_reference_form_renders_to_the_text_it_parsed_from() -> Result<(), RefError> {
for body in [
"row.magnitude",
"fixture.data_set",
"fixture.expected",
"fixture.defect",
"ehr_id",
"offset?",
"ds:cnf.set.bp-10",
"ds:cnf.set.bp-10#magnitude_ge_140_by_uid",
"ds:fixture",
"recipe:ehr_status(row)",
"ixit:system_id",
"ixit:dump_location",
"time:before(t1)",
"time:after(t1)",
"time:between(t1,t2)",
] {
let parsed = ValueRef::parse(body)?;
assert_eq!(parsed.to_string(), format!("${{{body}}}"));
let template = Template::parse(&parsed.to_string())?;
assert_eq!(template.as_single_ref(), Some(&parsed));
}
Ok(())
}
#[test]
fn a_template_renders_and_serializes_as_its_authored_text()
-> Result<(), Box<dyn std::error::Error>> {
let raw = "${versioned_object_uid}::<system>::2";
let template = Template::parse(raw)?;
assert_eq!(template.to_string(), raw);
assert_eq!(template.raw(), raw);
let json = serde_json::to_string(&template)?;
assert_eq!(json, format!("\"{raw}\""));
let back: Template = serde_json::from_str(&json)?;
assert_eq!(back, template);
Ok(())
}
#[test]
fn a_capture_source_renders_and_serializes_as_the_text_it_parsed_from()
-> Result<(), Box<dyn std::error::Error>> {
for raw in [
"created.ehr_id",
"created.version_uids[]",
"ok.body",
"created.commit_time",
] {
let source = CaptureValueSource::parse(raw)?;
assert_eq!(source.to_string(), raw);
assert_eq!(serde_json::to_string(&source)?, format!("\"{raw}\""));
let back: CaptureValueSource = serde_json::from_str(&format!("\"{raw}\""))?;
assert_eq!(back, source);
}
Ok(())
}
#[test]
fn a_capture_source_field_must_be_a_lexical_capture_name() {
let error = CaptureValueSource::parse("created.9lives").unwrap_err();
assert!(
matches!(&error, RefError::BadIdent { reference, .. } if reference == "created.9lives"),
"{error}"
);
}
}