use std::collections::BTreeMap;
use super::artifact::{Artifact, Prop, Type};
pub(crate) const FORMAT: &str = "arcature.page-contract.v1";
pub(crate) fn compare(backend: &Artifact, frontend: &Artifact) -> Result<(), String> {
if backend.format != FORMAT {
return Err(format!(
"backend artifact has unsupported format `{}`",
backend.format
));
}
if frontend.format != FORMAT {
return Err(format!(
"frontend artifact has unsupported format `{}`",
frontend.format
));
}
for (name, page) in &backend.pages {
let Some(frontend_page) = frontend.pages.get(name) else {
if let Some(actual) = frontend
.pages
.keys()
.find(|actual| actual.eq_ignore_ascii_case(name))
{
return Err(format!(
"page `{name}` is missing; frontend has `{actual}` with different casing"
));
}
return Err(format!(
"page `{name}` is registered by Rust but absent from frontend/arcature.contract.json"
));
};
compare_fields(name, "", &page.props.fields, &frontend_page.props.fields)?;
}
for name in frontend.pages.keys() {
if !backend.pages.contains_key(name) {
return Err(format!(
"frontend page `{name}` has no registered Rust page contract"
));
}
}
Ok(())
}
fn compare_fields(
page: &str,
prefix: &str,
backend: &BTreeMap<String, Prop>,
frontend: &BTreeMap<String, Prop>,
) -> Result<(), String> {
for (field, expected) in backend {
let path = path(prefix, field);
let Some(actual) = frontend.get(field) else {
return Err(format!(
"page `{page}` prop `{path}` is required by Rust but missing from the frontend contract"
));
};
if expected.required != actual.required {
return Err(format!(
"page `{page}` prop `{path}` requiredness differs: Rust is {}, frontend is {}",
required(expected),
required(actual)
));
}
compare_type(page, &path, &expected.ty, &actual.ty)?;
}
for field in frontend.keys() {
if !backend.contains_key(field) {
return Err(format!(
"page `{page}` frontend prop `{}` has no Rust contract",
path(prefix, field)
));
}
}
Ok(())
}
fn compare_type(page: &str, path: &str, expected: &Type, actual: &Type) -> Result<(), String> {
match (expected, actual) {
(Type::Array { item: expected }, Type::Array { item: actual })
| (Type::Nullable { item: expected }, Type::Nullable { item: actual }) => {
compare_type(page, path, expected, actual)
}
(Type::Object { fields: expected }, Type::Object { fields: actual }) => {
compare_fields(page, path, expected, actual)
}
_ if expected == actual => Ok(()),
_ => Err(format!(
"page `{page}` prop `{path}` type differs: Rust expects {}, frontend declares {}",
describe(expected),
describe(actual)
)),
}
}
fn path(prefix: &str, field: &str) -> String {
if prefix.is_empty() {
field.to_owned()
} else {
format!("{prefix}.{field}")
}
}
fn required(prop: &Prop) -> &'static str {
if prop.required {
"required"
} else {
"optional"
}
}
fn describe(ty: &Type) -> String {
match ty {
Type::Boolean => "boolean".to_owned(),
Type::Number => "number".to_owned(),
Type::String => "string".to_owned(),
Type::Array { item } => format!("{}[]", describe(item)),
Type::Nullable { item } => format!("{} | null", describe(item)),
Type::Object { .. } => "object".to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::contracts::artifact::{Page, Props};
fn artifact(page: &str, fields: BTreeMap<String, Prop>) -> Artifact {
Artifact {
format: FORMAT.to_owned(),
pages: BTreeMap::from([(
page.to_owned(),
Page {
props: Props { fields },
},
)]),
}
}
fn prop(ty: Type) -> Prop {
Prop { required: true, ty }
}
#[test]
fn reports_case_mismatch() {
let error = compare(
&artifact("Dashboard", BTreeMap::new()),
&artifact("dashboard", BTreeMap::new()),
)
.unwrap_err();
assert!(error.contains("different casing"));
}
#[test]
fn reports_nested_type_mismatch() {
let backend = artifact(
"Dashboard",
BTreeMap::from([(
"user".to_owned(),
prop(Type::Object {
fields: BTreeMap::from([("name".to_owned(), prop(Type::String))]),
}),
)]),
);
let frontend = artifact(
"Dashboard",
BTreeMap::from([(
"user".to_owned(),
prop(Type::Object {
fields: BTreeMap::from([("name".to_owned(), prop(Type::Number))]),
}),
)]),
);
let error = compare(&backend, &frontend).unwrap_err();
assert!(error.contains("user.name"));
}
#[test]
fn reports_missing_required_prop() {
let backend = artifact(
"Dashboard",
BTreeMap::from([("title".to_owned(), prop(Type::String))]),
);
let error = compare(&backend, &artifact("Dashboard", BTreeMap::new())).unwrap_err();
assert!(error.contains("title"));
}
#[test]
fn reports_stale_frontend_page() {
let backend = artifact("Dashboard", BTreeMap::new());
let frontend = Artifact {
format: FORMAT.to_owned(),
pages: BTreeMap::from([
(
"Dashboard".to_owned(),
Page {
props: Props {
fields: BTreeMap::new(),
},
},
),
(
"Removed".to_owned(),
Page {
props: Props {
fields: BTreeMap::new(),
},
},
),
]),
};
let error = compare(&backend, &frontend).unwrap_err();
assert!(error.contains("Removed"));
}
#[test]
fn reports_optional_mismatch() {
let backend = artifact(
"Dashboard",
BTreeMap::from([("title".to_owned(), prop(Type::String))]),
);
let frontend = artifact(
"Dashboard",
BTreeMap::from([(
"title".to_owned(),
Prop {
required: false,
ty: Type::String,
},
)]),
);
let error = compare(&backend, &frontend).unwrap_err();
assert!(error.contains("requiredness"));
}
}