use serde_json::{Map, Value, json};
pub fn codeable_concept(arguments: &[String]) -> Value {
match arguments {
[text] => json!({ "text": text }),
[system, code, rest @ ..] => json!({ "coding": [coding(system, code, rest.first())] }),
[] => json!({}),
}
}
pub fn coding(system: &str, code: &str, display: Option<&String>) -> Value {
let mut coding = Map::new();
coding.insert("system".into(), system.into());
coding.insert("code".into(), code.into());
if let Some(display) = display {
coding.insert("display".into(), display.as_str().into());
}
Value::Object(coding)
}
pub fn identifier(arguments: &[String]) -> Value {
let mut identifier = Map::new();
if let Some(system) = arguments.first() {
identifier.insert("system".into(), system.as_str().into());
}
if let Some(value) = arguments.get(1) {
identifier.insert("value".into(), value.as_str().into());
}
if let Some(code) = arguments.get(2) {
identifier.insert(
"type".into(),
json!({ "coding": [{
"system": "http://terminology.hl7.org/CodeSystem/v2-0203",
"code": code,
}]}),
);
}
Value::Object(identifier)
}
pub fn contact_point(arguments: &[String]) -> Value {
match arguments {
[system, value, ..] => json!({ "system": system, "value": value }),
[value] => {
let system = if value.contains('@') {
"email"
} else if value.starts_with("http://") || value.starts_with("https://") {
"url"
} else {
"phone"
};
let value = value.strip_prefix("mailto:").unwrap_or(value);
let value = value.strip_prefix("tel:").unwrap_or(value);
json!({ "system": system, "value": value })
}
[] => json!({}),
}
}
pub fn quantity(arguments: &[String]) -> Result<Value, String> {
let mut quantity = Map::new();
match arguments {
[text] => {
let mut rest = text.trim();
for comparator in ["<=", ">=", "<", ">", "ad"] {
if let Some(after) = rest.strip_prefix(comparator) {
quantity.insert("comparator".into(), comparator.into());
rest = after.trim_start();
break;
}
}
let (value, unit) = match rest.split_once(char::is_whitespace) {
Some((value, unit)) => (value, Some(unit.trim().trim_matches('\''))),
None => (rest, None),
};
quantity.insert("value".into(), number(value)?);
if let Some(unit) = unit {
quantity.insert("unit".into(), unit.into());
}
}
[value, unit, rest @ ..] => {
quantity.insert("value".into(), number(value)?);
quantity.insert("unit".into(), unit.as_str().into());
if let [system, code] = rest {
quantity.insert("system".into(), system.as_str().into());
quantity.insert("code".into(), code.as_str().into());
}
}
[] => return Err("qty needs a value".into()),
}
Ok(Value::Object(quantity))
}
fn number(text: &str) -> Result<Value, String> {
text.parse::<serde_json::Number>()
.map(Value::Number)
.map_err(|_| format!("`{text}` is not a number"))
}
pub fn cast_type(name: &str) -> String {
if let Some(name) = name.strip_prefix("FHIR.") {
return name.to_owned();
}
match name {
"System.Boolean" => "boolean",
"System.String" => "string",
"System.Integer" => "integer",
"System.Long" => "integer64",
"System.Decimal" => "decimal",
"System.Date" => "date",
"System.DateTime" => "dateTime",
"System.Time" => "time",
other => other,
}
.to_owned()
}
pub fn cast(value: &str, type_name: &str) -> Result<Value, String> {
let invalid = || format!("cannot cast `{value}` to {type_name}");
let valid = |ok: bool| {
if ok {
Ok(Value::String(value.to_owned()))
} else {
Err(invalid())
}
};
match type_name {
"boolean" => match value {
"true" => Ok(Value::Bool(true)),
"false" => Ok(Value::Bool(false)),
_ => Err(invalid()),
},
"integer" | "positiveInt" | "unsignedInt" => {
let number: i32 = value.trim().parse().map_err(|_| invalid())?;
let minimum = match type_name {
"positiveInt" => 1,
"unsignedInt" => 0,
_ => i32::MIN,
};
if number < minimum {
return Err(invalid());
}
Ok(Value::from(number))
}
"integer64" => valid(value.trim().parse::<i64>().is_ok()),
"decimal" => number(value.trim()).map_err(|_| invalid()),
"date" => valid(is_date(value)),
"dateTime" => valid(is_date_time(value)),
"instant" => valid(is_instant(value)),
"time" => valid(is_time(value)),
"id" => valid(
(1..=64).contains(&value.len())
&& value
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.'),
),
"code" => valid(!value.is_empty() && value.trim() == value && !value.contains(" ")),
"string" | "markdown" | "uri" | "url" | "canonical" | "oid" | "uuid" | "base64Binary" => {
valid(!value.is_empty())
}
other => Err(format!("cast to {other} is not supported")),
}
}
fn is_date(text: &str) -> bool {
let parts: Vec<&str> = text.split('-').collect();
let digits =
|part: &str, len: usize| part.len() == len && part.bytes().all(|b| b.is_ascii_digit());
let in_range = |part: &str, low: u32, high: u32| {
part.parse::<u32>().is_ok_and(|n| (low..=high).contains(&n))
};
match parts.as_slice() {
[year] => digits(year, 4),
[year, month] => digits(year, 4) && digits(month, 2) && in_range(month, 1, 12),
[year, month, day] => {
digits(year, 4)
&& digits(month, 2)
&& in_range(month, 1, 12)
&& digits(day, 2)
&& in_range(day, 1, 31)
}
_ => false,
}
}
fn is_time(text: &str) -> bool {
let (clock, fraction) = match text.split_once('.') {
Some((clock, fraction)) => (clock, Some(fraction)),
None => (text, None),
};
let parts: Vec<&str> = clock.split(':').collect();
let field =
|part: &str, high: u32| part.len() == 2 && part.parse::<u32>().is_ok_and(|n| n <= high);
matches!(parts.as_slice(), [h, m, s] if field(h, 23) && field(m, 59) && field(s, 60))
&& fraction.is_none_or(|f| !f.is_empty() && f.bytes().all(|b| b.is_ascii_digit()))
}
fn is_zone(text: &str) -> bool {
text == "Z"
|| (text.len() == 6
&& matches!(&text[..1], "+" | "-")
&& &text[3..4] == ":"
&& text[1..3].parse::<u32>().is_ok_and(|h| h <= 14)
&& text[4..].parse::<u32>().is_ok_and(|m| m <= 59))
}
fn is_instant(text: &str) -> bool {
let Some((date, rest)) = text.split_once('T') else {
return false;
};
let split = rest.find(['Z', '+', '-']).unwrap_or(rest.len());
date.len() == 10 && is_date(date) && is_time(&rest[..split]) && is_zone(&rest[split..])
}
fn is_date_time(text: &str) -> bool {
if text.contains('T') {
is_instant(text)
} else {
is_date(text)
}
}