arcature-cli 2026.2.0

Developer lifecycle CLI for Arcature applications.
Documentation
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"));
    }
}