cratestack-cli 0.6.5

Rust-native schema-first framework for typed HTTP APIs, generated clients, and backend services.
use std::collections::BTreeMap;

use cratestack_core::{Field, TypeArity};

use super::arity::{arity_label, classify_arity_change};
use super::{Change, Severity};

pub(super) fn diff_fields(
    model_name: &str,
    prev: &[Field],
    next: &[Field],
    changes: &mut Vec<Change>,
) {
    let prev_by_name = index(prev);
    let next_by_name = index(next);

    for name in prev_by_name.keys() {
        if !next_by_name.contains_key(name) {
            changes.push(Change {
                severity: Severity::Breaking,
                category: "field_removed",
                subject: format!("{model_name}.{name}"),
                message: format!("field `{model_name}.{name}` was removed"),
            });
        }
    }

    for (name, next_field) in &next_by_name {
        match prev_by_name.get(name) {
            None => push_added_field(changes, model_name, name, next_field),
            Some(prev_field) => {
                diff_matched_field(model_name, name, prev_field, next_field, changes)
            }
        }
    }
}

fn index(fields: &[Field]) -> BTreeMap<&str, &Field> {
    fields
        .iter()
        .map(|field| (field.name.as_str(), field))
        .collect()
}

fn push_added_field(changes: &mut Vec<Change>, model_name: &str, name: &str, field: &Field) {
    let breaks_construction =
        field.ty.arity == TypeArity::Required && !has_default(field) && !is_server_only(field);

    if breaks_construction {
        changes.push(Change {
            severity: Severity::Breaking,
            category: "field_added_required",
            subject: format!("{model_name}.{name}"),
            message: format!(
                "field `{model_name}.{name}` was added as a required field with no default \
                 — existing client payloads that omit it will be rejected"
            ),
        });
        return;
    }

    changes.push(Change {
        severity: Severity::Additive,
        category: "field_added",
        subject: format!("{model_name}.{name}"),
        message: format!("field `{model_name}.{name}` was added"),
    });
}

fn diff_matched_field(
    model_name: &str,
    name: &str,
    prev: &Field,
    next: &Field,
    changes: &mut Vec<Change>,
) {
    if prev.ty.name != next.ty.name {
        changes.push(Change {
            severity: Severity::Breaking,
            category: "field_retyped",
            subject: format!("{model_name}.{name}"),
            message: format!(
                "field `{model_name}.{name}` type changed from `{}` to `{}`",
                prev.ty.name, next.ty.name
            ),
        });
        return;
    }

    if prev.ty.arity != next.ty.arity {
        let severity = classify_arity_change(prev.ty.arity, next.ty.arity);
        changes.push(Change {
            severity,
            category: "field_arity_changed",
            subject: format!("{model_name}.{name}"),
            message: format!(
                "field `{model_name}.{name}` arity changed from {} to {}",
                arity_label(prev.ty.arity),
                arity_label(next.ty.arity)
            ),
        });
    }
}

fn has_default(field: &Field) -> bool {
    field
        .attributes
        .iter()
        .any(|attribute| attribute.raw.starts_with("@default"))
}

fn is_server_only(field: &Field) -> bool {
    field
        .attributes
        .iter()
        .any(|attribute| attribute.raw == "@server_only")
}