cratestack-cli 0.7.2

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

use cratestack_core::{Procedure, ProcedureArg, Schema, TypeArity};

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

pub(super) fn diff_procedures(prev: &Schema, next: &Schema, changes: &mut Vec<Change>) {
    let prev_by_name = index(&prev.procedures);
    let next_by_name = index(&next.procedures);

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

    for name in next_by_name.keys() {
        if !prev_by_name.contains_key(name) {
            changes.push(Change {
                severity: Severity::Additive,
                category: "procedure_added",
                subject: format!("procedure `{name}`"),
                message: format!("procedure `{name}` was added"),
            });
        }
    }

    for (name, prev_proc) in &prev_by_name {
        let Some(next_proc) = next_by_name.get(name) else {
            continue;
        };
        diff_matched_procedure(name, prev_proc, next_proc, changes);
    }
}

fn index(procedures: &[Procedure]) -> BTreeMap<&str, &Procedure> {
    procedures
        .iter()
        .map(|proc| (proc.name.as_str(), proc))
        .collect()
}

fn diff_matched_procedure(
    name: &str,
    prev: &Procedure,
    next: &Procedure,
    changes: &mut Vec<Change>,
) {
    if prev.kind != next.kind {
        changes.push(Change {
            severity: Severity::Breaking,
            category: "procedure_kind_changed",
            subject: format!("procedure `{name}`"),
            message: format!(
                "procedure `{name}` kind changed from {:?} to {:?} (query/mutation dispatch differs)",
                prev.kind, next.kind
            ),
        });
    }

    if prev.return_type.name != next.return_type.name {
        changes.push(Change {
            severity: Severity::Breaking,
            category: "procedure_return_type_changed",
            subject: format!("procedure `{name}`"),
            message: format!(
                "procedure `{name}` return type changed from `{}` to `{}`",
                prev.return_type.name, next.return_type.name
            ),
        });
    } else if prev.return_type.arity != next.return_type.arity {
        let severity = classify_arity_change(prev.return_type.arity, next.return_type.arity);
        changes.push(Change {
            severity,
            category: "procedure_return_arity_changed",
            subject: format!("procedure `{name}`"),
            message: format!(
                "procedure `{name}` return type arity changed from {} to {}",
                arity_label(prev.return_type.arity),
                arity_label(next.return_type.arity)
            ),
        });
    }

    diff_args(name, &prev.args, &next.args, changes);
}

fn diff_args(
    proc_name: &str,
    prev: &[ProcedureArg],
    next: &[ProcedureArg],
    changes: &mut Vec<Change>,
) {
    let prev_by_name = index_args(prev);
    let next_by_name = index_args(next);

    for name in prev_by_name.keys() {
        if !next_by_name.contains_key(name) {
            changes.push(Change {
                severity: Severity::Breaking,
                category: "procedure_arg_removed",
                subject: format!("{proc_name}({name})"),
                message: format!("procedure `{proc_name}` lost argument `{name}`"),
            });
        }
    }

    for (name, next_arg) in &next_by_name {
        match prev_by_name.get(name) {
            None => push_added_arg(changes, proc_name, name, next_arg),
            Some(prev_arg) => diff_matched_arg(proc_name, name, prev_arg, next_arg, changes),
        }
    }
}

fn index_args(args: &[ProcedureArg]) -> BTreeMap<&str, &ProcedureArg> {
    args.iter().map(|arg| (arg.name.as_str(), arg)).collect()
}

fn push_added_arg(changes: &mut Vec<Change>, proc_name: &str, name: &str, arg: &ProcedureArg) {
    if arg.ty.arity == TypeArity::Required {
        changes.push(Change {
            severity: Severity::Breaking,
            category: "procedure_arg_added_required",
            subject: format!("{proc_name}({name})"),
            message: format!(
                "procedure `{proc_name}` gained required argument `{name}` \
                 — existing callers omitting it will fail"
            ),
        });
        return;
    }

    changes.push(Change {
        severity: Severity::Additive,
        category: "procedure_arg_added",
        subject: format!("{proc_name}({name})"),
        message: format!("procedure `{proc_name}` gained optional argument `{name}`"),
    });
}

fn diff_matched_arg(
    proc_name: &str,
    name: &str,
    prev: &ProcedureArg,
    next: &ProcedureArg,
    changes: &mut Vec<Change>,
) {
    if prev.ty.name != next.ty.name {
        changes.push(Change {
            severity: Severity::Breaking,
            category: "procedure_arg_retyped",
            subject: format!("{proc_name}({name})"),
            message: format!(
                "procedure `{proc_name}` argument `{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: "procedure_arg_arity_changed",
            subject: format!("{proc_name}({name})"),
            message: format!(
                "procedure `{proc_name}` argument `{name}` arity changed from {} to {}",
                arity_label(prev.ty.arity),
                arity_label(next.ty.arity)
            ),
        });
    }
}