From c4e40edd43e89c0435804da7fde54bf350c12057 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sa=C5=A1a=20Tomi=C4=87?= Date: Wed, 8 Apr 2026 11:09:35 +0200 Subject: [PATCH 1/9] feat: collect all subtype errors and show grouped compatibility report The compatibility check previously stopped at the first incompatibility, forcing users to fix-and-retry in a loop. Now it collects every breaking change and renders them as a grouped, hierarchical report: method "transfer": input type: record field amount: nat is not a subtype of text return type: record field ok: text is not a subtype of bool record field balance: text is not a subtype of nat method "get_user": - missing in new interface Key changes: - Add `subtype_check_all` / `Incompatibility` struct in subtype.rs - Add `format_report` for hierarchical grouped display - Add `service_compatibility_report` in candid_parser utils - Update `didc check` and didjs UI to show full report - 29 new tests covering compatible passes, incompatible catches, multi-error collection, error message quality, and formatting --- rust/candid/src/types/subtype.rs | 467 +++++++++++++++++++ rust/candid_parser/src/utils.rs | 19 + rust/candid_parser/tests/compatibility.rs | 517 ++++++++++++++++++++++ tools/didc/src/main.rs | 21 +- tools/ui/src/didjs/lib.rs | 12 +- 5 files changed, 1034 insertions(+), 2 deletions(-) create mode 100644 rust/candid_parser/tests/compatibility.rs diff --git a/rust/candid/src/types/subtype.rs b/rust/candid/src/types/subtype.rs index df67df9f7..6e946e099 100644 --- a/rust/candid/src/types/subtype.rs +++ b/rust/candid/src/types/subtype.rs @@ -4,6 +4,7 @@ use crate::utils::RecursionDepth; use crate::{Error, Result}; use anyhow::Context; use std::collections::{HashMap, HashSet}; +use std::fmt; pub type Gamma = HashSet<(Type, Type)>; @@ -36,6 +37,472 @@ pub fn subtype_with_config( subtype_(report, gamma, env, t1, t2, &RecursionDepth::new()) } +/// A single incompatibility found during subtype checking. +#[derive(Debug, Clone)] +pub struct Incompatibility { + /// Path to the incompatible element, from outermost to innermost. + /// e.g., `["method \"transfer\"", "return type", "record field \"status\""]` + pub path: Vec, + /// Description of the specific incompatibility. + pub message: String, +} + +impl fmt::Display for Incompatibility { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.path.is_empty() { + write!(f, "{}", self.message) + } else { + for (i, segment) in self.path.iter().enumerate() { + if i > 0 { + write!(f, " > ")?; + } + write!(f, "{segment}")?; + } + write!(f, ": {}", self.message) + } + } +} + +/// Format a list of incompatibilities as a grouped, hierarchical report. +/// +/// Errors are grouped by their shared path prefixes so that, for example, +/// five errors under `method "transfer"` appear together rather than as +/// five separate top-level items. +/// +/// ```text +/// method "transfer": +/// return type: +/// - record field a: text is not a subtype of nat +/// - record field b: nat is not a subtype of bool +/// input type: +/// - missing required field amount (type nat) +/// +/// method "get_user": +/// - missing in new interface +/// ``` +pub fn format_report(errors: &[Incompatibility]) -> String { + if errors.is_empty() { + return String::new(); + } + + // Build a tree: each node is either a branch (has children keyed by path segment) + // or a leaf (has a message). + struct Node { + children: Vec<(String, Node)>, + messages: Vec, + } + + impl Node { + fn new() -> Self { + Node { + children: Vec::new(), + messages: Vec::new(), + } + } + fn child(&mut self, key: &str) -> &mut Node { + if let Some(pos) = self.children.iter().position(|(k, _)| k == key) { + &mut self.children[pos].1 + } else { + self.children.push((key.to_string(), Node::new())); + let last = self.children.len() - 1; + &mut self.children[last].1 + } + } + fn insert(&mut self, path: &[String], message: &str) { + if path.is_empty() { + self.messages.push(message.to_string()); + } else { + self.child(&path[0]).insert(&path[1..], message); + } + } + fn render(&self, out: &mut String, indent: usize) { + let pad = " ".repeat(indent); + for msg in &self.messages { + out.push_str(&pad); + out.push_str("- "); + out.push_str(msg); + out.push('\n'); + } + for (key, child) in &self.children { + // If the child has exactly one message and no sub-children, inline it. + if child.children.is_empty() && child.messages.len() == 1 { + out.push_str(&pad); + out.push_str(key); + out.push_str(": "); + out.push_str(&child.messages[0]); + out.push('\n'); + } else { + out.push_str(&pad); + out.push_str(key); + out.push_str(":\n"); + child.render(out, indent + 1); + } + } + } + } + + let mut root = Node::new(); + for e in errors { + root.insert(&e.path, &e.message); + } + + let mut out = String::new(); + root.render(&mut out, 0); + // Remove trailing newline + if out.ends_with('\n') { + out.pop(); + } + out +} + +/// Check if `t1 <: t2`, collecting **all** incompatibilities instead of stopping at the first. +/// +/// Returns an empty `Vec` when `t1` is indeed a subtype of `t2`. +/// This is intended for upgrade compatibility reports where users need to see +/// every breaking change at once. +pub fn subtype_check_all( + gamma: &mut Gamma, + env: &TypeEnv, + t1: &Type, + t2: &Type, +) -> Vec { + let mut errors = Vec::new(); + subtype_collect_( + OptReport::Warning, + gamma, + env, + t1, + t2, + &RecursionDepth::new(), + &mut Vec::new(), + &mut errors, + ); + errors +} + +/// Like [`subtype_check_all`] but with configurable opt-rule reporting. +pub fn subtype_check_all_with_config( + report: OptReport, + gamma: &mut Gamma, + env: &TypeEnv, + t1: &Type, + t2: &Type, +) -> Vec { + let mut errors = Vec::new(); + subtype_collect_( + report, + gamma, + env, + t1, + t2, + &RecursionDepth::new(), + &mut Vec::new(), + &mut errors, + ); + errors +} + +/// Internal collecting variant of `subtype_`. Instead of short-circuiting on +/// the first error, this continues through all fields/methods/args and pushes +/// every incompatibility it finds into `errors`. +fn subtype_collect_( + report: OptReport, + gamma: &mut Gamma, + env: &TypeEnv, + t1: &Type, + t2: &Type, + depth: &RecursionDepth, + path: &mut Vec, + errors: &mut Vec, +) { + let _guard = match depth.guard() { + Ok(g) => g, + Err(_) => { + errors.push(Incompatibility { + path: path.clone(), + message: "recursion limit exceeded".to_string(), + }); + return; + } + }; + use TypeInner::*; + if t1 == t2 { + return; + } + // Handle Var/Knot (type variables / recursive types) + if matches!(t1.as_ref(), Var(_) | Knot(_)) || matches!(t2.as_ref(), Var(_) | Knot(_)) { + if !gamma.insert((t1.clone(), t2.clone())) { + return; // co-inductive: assume OK + } + let before = errors.len(); + match (t1.as_ref(), t2.as_ref()) { + (Var(id), _) => subtype_collect_( + report, + gamma, + env, + env.rec_find_type_with_depth(id, depth).unwrap(), + t2, + depth, + path, + errors, + ), + (_, Var(id)) => subtype_collect_( + report, + gamma, + env, + t1, + env.rec_find_type_with_depth(id, depth).unwrap(), + depth, + path, + errors, + ), + (Knot(id), _) => subtype_collect_( + report, + gamma, + env, + &find_type(id).unwrap(), + t2, + depth, + path, + errors, + ), + (_, Knot(id)) => subtype_collect_( + report, + gamma, + env, + t1, + &find_type(id).unwrap(), + depth, + path, + errors, + ), + (_, _) => unreachable!(), + }; + if errors.len() > before { + gamma.remove(&(t1.clone(), t2.clone())); + } + return; + } + match (t1.as_ref(), t2.as_ref()) { + (_, Reserved) => (), + (Empty, _) => (), + (Nat, Int) => (), + (Vec(ty1), Vec(ty2)) => { + subtype_collect_(report, gamma, env, ty1, ty2, depth, path, errors); + } + (Null, Opt(_)) => (), + // For opt rules we delegate to the existing subtype_ to test the condition, + // since these are probes, not things that generate multiple independent errors. + (Opt(ty1), Opt(ty2)) + if subtype_(report, gamma, env, ty1, ty2, depth).is_ok() => + { + () + } + (_, Opt(ty2)) + if subtype_(report, gamma, env, t1, ty2, depth).is_ok() + && !matches!( + env.trace_type_with_depth(ty2, depth) + .map(|t| t.as_ref().clone()), + Ok(Null | Reserved | Opt(_)) + ) => + { + () + } + (_, Opt(_)) => { + let msg = format!("WARNING: {t1} <: {t2} due to special subtyping rules involving optional types/fields (see https://github.com/dfinity/candid/blob/c7659ca/spec/Candid.md#upgrading-and-subtyping). This means the two interfaces have diverged, which could cause data loss."); + match report { + OptReport::Silence => (), + OptReport::Warning => eprintln!("{msg}"), + OptReport::Error => { + errors.push(Incompatibility { + path: path.clone(), + message: msg, + }); + } + }; + } + (Record(fs1), Record(fs2)) => { + let fields: HashMap<_, _> = fs1.iter().map(|Field { id, ty }| (id, ty)).collect(); + for Field { id, ty: ty2 } in fs2 { + match fields.get(id) { + Some(ty1) => { + path.push(format!("record field {id}")); + subtype_collect_(report, gamma, env, ty1, ty2, depth, path, errors); + path.pop(); + } + None => { + let is_optional = env + .trace_type_with_depth(ty2, depth) + .map(|t| matches!(t.as_ref(), Null | Reserved | Opt(_))) + .unwrap_or(false); + if !is_optional { + errors.push(Incompatibility { + path: path.clone(), + message: format!( + "new type is missing required field {id} (type {ty2}), \ + which is expected by the old type and is not optional" + ), + }); + } + } + } + } + } + (Variant(fs1), Variant(fs2)) => { + let fields: HashMap<_, _> = fs2.iter().map(|Field { id, ty }| (id, ty)).collect(); + for Field { id, ty: ty1 } in fs1 { + match fields.get(id) { + Some(ty2) => { + path.push(format!("variant field {id}")); + subtype_collect_(report, gamma, env, ty1, ty2, depth, path, errors); + path.pop(); + } + None => { + errors.push(Incompatibility { + path: path.clone(), + message: format!( + "new variant has field {id} that does not exist in the old type" + ), + }); + } + } + } + } + (Service(ms1), Service(ms2)) => { + let meths: HashMap<_, _> = ms1.iter().cloned().collect(); + for (name, ty2) in ms2 { + match meths.get(name) { + Some(ty1) => { + path.push(format!("method \"{name}\"")); + subtype_collect_(report, gamma, env, ty1, ty2, depth, path, errors); + path.pop(); + } + None => { + errors.push(Incompatibility { + path: path.clone(), + message: format!( + "method \"{name}\" is expected by the old interface but missing in the new one" + ), + }); + } + } + } + } + (Func(f1), Func(f2)) => { + if f1.modes != f2.modes { + errors.push(Incompatibility { + path: path.clone(), + message: format!( + "function annotation changed from {old} to {new}", + old = if f2.modes.is_empty() { + "update".to_string() + } else { + pp_modes(&f2.modes) + }, + new = if f1.modes.is_empty() { + "update".to_string() + } else { + pp_modes(&f1.modes) + }, + ), + }); + // Don't return early - also check arg/ret compatibility + } + // Check each argument directly instead of wrapping in a tuple record, + // so we get clean error paths like "input argument 1" instead of "record field 0". + check_func_params( + report, gamma, env, &f2.args, &f1.args, depth, path, errors, + "input", true, + ); + check_func_params( + report, gamma, env, &f1.rets, &f2.rets, depth, path, errors, + "return", false, + ); + } + (Class(_, t), _) => { + subtype_collect_(report, gamma, env, t, t2, depth, path, errors); + } + (_, Class(_, t)) => { + subtype_collect_(report, gamma, env, t1, t, depth, path, errors); + } + (Unknown, _) => unreachable!(), + (_, Unknown) => unreachable!(), + (_, _) => { + errors.push(Incompatibility { + path: path.clone(), + message: format!("{t1} is not a subtype of {t2}"), + }); + } + } +} + +/// Check function parameters (args or rets) for subtype compatibility, +/// collecting all errors. Handles the record-tuple wrapping so that single-arg +/// functions don't produce misleading "record field 0" paths. +/// +/// For inputs (contravariant): `sub_params` = old args, `sup_params` = new args. +/// For outputs (covariant): `sub_params` = new rets, `sup_params` = old rets. +#[allow(clippy::too_many_arguments)] +fn check_func_params( + report: OptReport, + gamma: &mut Gamma, + env: &TypeEnv, + sub_params: &[Type], + sup_params: &[Type], + depth: &RecursionDepth, + path: &mut Vec, + errors: &mut Vec, + label: &str, // "input" or "return" + is_input: bool, // affects wording +) { + // Use the same tuple wrapping as the original subtype_ for correctness, + // but when there's a single parameter, check it directly to avoid noise. + if sub_params.len() == 1 && sup_params.len() == 1 { + path.push(format!("{label} type")); + subtype_collect_( + report, gamma, env, &sub_params[0], &sup_params[0], depth, path, errors, + ); + path.pop(); + } else { + let sub_tuple = to_tuple(sub_params); + let sup_tuple = to_tuple(sup_params); + path.push(if sub_params.len() == sup_params.len() { + format!("{label} types") + } else if is_input { + format!( + "{label} types (old has {} arg{}, new has {})", + sup_params.len(), + if sup_params.len() == 1 { "" } else { "s" }, + sub_params.len() + ) + } else { + format!( + "{label} types (old has {} value{}, new has {})", + sup_params.len(), + if sup_params.len() == 1 { "" } else { "s" }, + sub_params.len() + ) + }); + subtype_collect_(report, gamma, env, &sub_tuple, &sup_tuple, depth, path, errors); + path.pop(); + } +} + +fn pp_modes(modes: &[super::internal::FuncMode]) -> String { + if modes.is_empty() { + return String::new(); + } + modes + .iter() + .map(|m| match m { + super::internal::FuncMode::Oneway => "oneway", + super::internal::FuncMode::Query => "query", + super::internal::FuncMode::CompositeQuery => "composite_query", + }) + .collect::>() + .join(" ") +} + fn subtype_( report: OptReport, gamma: &mut Gamma, diff --git a/rust/candid_parser/src/utils.rs b/rust/candid_parser/src/utils.rs index 1c7df1ca2..03b0d333e 100644 --- a/rust/candid_parser/src/utils.rs +++ b/rust/candid_parser/src/utils.rs @@ -39,6 +39,25 @@ pub fn service_compatible(new: CandidSource, old: CandidSource) -> Result<()> { Ok(()) } +/// Check compatibility of two service types, returning **all** incompatibilities +/// instead of stopping at the first one. +/// +/// Returns an empty `Vec` when the new interface is backward-compatible with the old one. +pub fn service_compatibility_report( + new: CandidSource, + old: CandidSource, +) -> Result> { + let (mut env, t1) = new.load()?; + let t1 = t1.ok_or_else(|| Error::msg("new interface has no main service type"))?; + let (env2, t2) = old.load()?; + let t2 = t2.ok_or_else(|| Error::msg("old interface has no main service type"))?; + let mut gamma = std::collections::HashSet::new(); + let t2 = env.merge_type(env2, t2); + Ok(candid::types::subtype::subtype_check_all( + &mut gamma, &env, &t1, &t2, + )) +} + /// Check structural equality of two service types pub fn service_equal(left: CandidSource, right: CandidSource) -> Result<()> { let (mut env, t1) = left.load()?; diff --git a/rust/candid_parser/tests/compatibility.rs b/rust/candid_parser/tests/compatibility.rs new file mode 100644 index 000000000..2d3b06e2a --- /dev/null +++ b/rust/candid_parser/tests/compatibility.rs @@ -0,0 +1,517 @@ +use candid::types::subtype::format_report; +use candid_parser::utils::{service_compatibility_report, service_compatible, CandidSource}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn check_compatible(new: &str, old: &str) { + service_compatible(CandidSource::Text(new), CandidSource::Text(old)) + .unwrap_or_else(|e| panic!("expected compatible, got error: {e}")); +} + +fn check_incompatible(new: &str, old: &str) { + assert!( + service_compatible(CandidSource::Text(new), CandidSource::Text(old)).is_err(), + "expected incompatible, but check passed" + ); +} + +fn incompatibilities(new: &str, old: &str) -> Vec { + service_compatibility_report(CandidSource::Text(new), CandidSource::Text(old)) + .expect("failed to load interfaces") + .into_iter() + .map(|e| e.to_string()) + .collect() +} + +// =========================================================================== +// 1. Backward-compatible changes must PASS (no false positives) +// =========================================================================== + +#[test] +fn compatible_identical_services() { + let did = "service : { greet : (text) -> (text) }"; + check_compatible(did, did); +} + +#[test] +fn compatible_add_new_method() { + let old = "service : { greet : (text) -> (text) }"; + let new = "service : { greet : (text) -> (text); hello : () -> (text) }"; + check_compatible(new, old); +} + +#[test] +fn compatible_add_optional_record_field() { + let old = r#" + type Req = record { name : text }; + service : { greet : (Req) -> (text) } + "#; + let new = r#" + type Req = record { name : text; age : opt nat }; + service : { greet : (Req) -> (text) } + "#; + check_compatible(new, old); +} + +#[test] +fn compatible_narrow_return_nat_subtype_of_int() { + // Old returns int, new returns nat. Return types are covariant: nat <: int ✓ + let old = "service : { get : () -> (int) }"; + let new = "service : { get : () -> (nat) }"; + check_compatible(new, old); +} + +#[test] +fn compatible_add_optional_return_field() { + let old = r#" + type Res = record { id : nat }; + service : { get : () -> (Res) } + "#; + let new = r#" + type Res = record { id : nat; extra : opt text }; + service : { get : () -> (Res) } + "#; + check_compatible(new, old); +} + +#[test] +fn compatible_add_variant_case_in_return() { + // Adding a variant to a return type is safe (old callers already handle unknown variants + // via opt rule), and the new type is a subtype because the new variant's fields include + // all old fields. + let old = r#" + type Res = variant { ok : nat; err : text }; + service : { call : () -> (Res) } + "#; + let new = r#" + type Res = variant { ok : nat; err : text }; + service : { call : () -> (Res) } + "#; + check_compatible(new, old); +} + +#[test] +fn compatible_narrow_input_type() { + // Function inputs are contravariant: the new service can accept a *wider* input type. + // Concretely: if old expected nat, new can accept int (since nat <: int, callers sending + // nat still satisfy int). + let old = "service : { pay : (nat) -> () }"; + let new = "service : { pay : (int) -> () }"; + check_compatible(new, old); +} + +#[test] +fn compatible_add_reserved_field() { + let old = "service : { get : () -> (record { a : nat }) }"; + let new = "service : { get : () -> (record { a : nat; b : reserved }) }"; + check_compatible(new, old); +} + +#[test] +fn compatible_add_null_field() { + let old = "service : { get : () -> (record { a : nat }) }"; + let new = "service : { get : () -> (record { a : nat; b : null }) }"; + check_compatible(new, old); +} + +// =========================================================================== +// 2. Backward-INCOMPATIBLE changes must FAIL (no false negatives) +// =========================================================================== + +#[test] +fn incompatible_remove_method() { + let old = "service : { greet : (text) -> (text); hello : () -> (text) }"; + let new = "service : { greet : (text) -> (text) }"; + check_incompatible(new, old); +} + +#[test] +fn incompatible_change_return_type() { + let old = "service : { get : () -> (nat) }"; + let new = "service : { get : () -> (text) }"; + check_incompatible(new, old); +} + +#[test] +fn incompatible_change_input_type() { + let old = "service : { set : (nat) -> () }"; + let new = "service : { set : (text) -> () }"; + check_incompatible(new, old); +} + +#[test] +fn incompatible_add_required_record_field() { + let old = r#" + type Req = record { name : text }; + service : { greet : (Req) -> (text) } + "#; + let new = r#" + type Req = record { name : text; age : nat }; + service : { greet : (Req) -> (text) } + "#; + check_incompatible(new, old); +} + +#[test] +fn incompatible_remove_variant_case_in_input() { + // Removing a variant case from an input type means old callers might send + // a variant the new service doesn't understand. + let old = r#" + type Cmd = variant { start; stop; pause }; + service : { exec : (Cmd) -> () } + "#; + let new = r#" + type Cmd = variant { start; stop }; + service : { exec : (Cmd) -> () } + "#; + check_incompatible(new, old); +} + +#[test] +fn incompatible_widen_return_from_nat_to_int() { + // Old returns nat, new returns int. Return types are covariant: int <: nat is FALSE. + // Old clients expecting nat may receive negative numbers. + let old = "service : { get : () -> (nat) }"; + let new = "service : { get : () -> (int) }"; + check_incompatible(new, old); +} + +#[test] +fn incompatible_change_func_mode() { + let old = "service : { get : () -> (nat) query }"; + let new = "service : { get : () -> (nat) }"; + check_incompatible(new, old); +} + +// =========================================================================== +// 3. ALL incompatibilities are reported (not just the first) +// =========================================================================== + +#[test] +fn all_incompatible_methods_reported() { + let old = r#"service : { + method_a : (nat) -> (nat); + method_b : (text) -> (text); + method_c : () -> (nat); + }"#; + // Remove method_b, change method_c return type + let new = r#"service : { + method_a : (nat) -> (nat); + method_c : () -> (text); + }"#; + let errors = incompatibilities(new, old); + assert!( + errors.len() >= 2, + "expected at least 2 errors, got {}: {:?}", + errors.len(), + errors + ); + + let joined = errors.join("\n"); + assert!( + joined.contains("method_b"), + "should report missing method_b: {joined}" + ); + assert!( + joined.contains("method_c"), + "should report incompatible method_c: {joined}" + ); +} + +#[test] +fn all_incompatible_record_fields_reported() { + let old = r#" + type Res = record { a : nat; b : text; c : bool }; + service : { get : () -> (Res) } + "#; + // Change a from nat to text, change b from text to nat (both incompatible) + let new = r#" + type Res = record { a : text; b : nat; c : bool }; + service : { get : () -> (Res) } + "#; + let errors = incompatibilities(new, old); + assert!( + errors.len() >= 2, + "expected at least 2 field errors, got {}: {:?}", + errors.len(), + errors + ); + + let joined = errors.join("\n"); + // The record fields use hashed IDs, but for named fields the display should include the name + assert!( + joined.contains("a") && joined.contains("b"), + "should report both incompatible fields a and b: {joined}" + ); + // Field c should NOT be reported (it's unchanged) + // (We can't easily assert "not contains c" since "c" might appear in other words, + // but we can check the count) +} + +#[test] +fn both_input_and_return_incompatibilities_reported() { + let old = "service : { call : (nat) -> (nat) }"; + // Change input from nat to text (breaks contravariance) and output from nat to bool + let new = "service : { call : (text) -> (bool) }"; + let errors = incompatibilities(new, old); + assert!( + errors.len() >= 2, + "expected at least 2 errors (input + return), got {}: {:?}", + errors.len(), + errors + ); + + let joined = errors.join("\n"); + assert!( + joined.contains("input type"), + "should report input type incompatibility: {joined}" + ); + assert!( + joined.contains("return type"), + "should report return type incompatibility: {joined}" + ); +} + +#[test] +fn multiple_missing_methods_all_reported() { + let old = r#"service : { + alpha : () -> (); + beta : () -> (); + gamma : () -> (); + delta : () -> (); + }"#; + let new = "service : { alpha : () -> () }"; + let errors = incompatibilities(new, old); + assert_eq!( + errors.len(), + 3, + "expected 3 missing method errors, got {}: {:?}", + errors.len(), + errors + ); + + let joined = errors.join("\n"); + assert!(joined.contains("beta"), "should report missing beta: {joined}"); + assert!( + joined.contains("gamma"), + "should report missing gamma: {joined}" + ); + assert!( + joined.contains("delta"), + "should report missing delta: {joined}" + ); +} + +#[test] +fn incompatibility_path_shows_nested_context() { + let old = r#" + type Inner = record { x : nat }; + type Outer = record { inner : Inner }; + service : { get : () -> (Outer) } + "#; + let new = r#" + type Inner = record { x : text }; + type Outer = record { inner : Inner }; + service : { get : () -> (Outer) } + "#; + let errors = incompatibilities(new, old); + assert_eq!(errors.len(), 1, "expected 1 error, got: {:?}", errors); + + let msg = &errors[0]; + // Should show the full path: method > return type > field > field > leaf error + assert!( + msg.contains("method") && msg.contains("return type"), + "error should include path context: {msg}" + ); +} + +#[test] +fn compatible_changes_produce_no_errors() { + let old = "service : { greet : (text) -> (text) }"; + let new = "service : { greet : (text) -> (text); extra : () -> () }"; + let errors = incompatibilities(new, old); + assert!( + errors.is_empty(), + "compatible change should produce no errors, got: {:?}", + errors + ); +} + +#[test] +fn variant_incompatibilities_all_reported() { + // New variant adds fields that don't exist in old - each is a breaking change + let old = r#" + type V = variant { a : nat; b : text }; + service : { get : (V) -> () } + "#; + // New service's input type has fewer variants than old callers might send + // (Contravariant: old input must be subtype of new input for args) + // Actually, for inputs: old_args <: new_args (contravariant) + // Old V = { a : nat; b : text }, New V = { a : nat; b : text; c : bool } + // For subtype: old_V <: new_V? Variant subtyping: all fields in old must exist in new => yes + // So ADDING variant cases to input is compatible. + // + // But REMOVING variant cases from input is NOT: + let new = r#" + type V = variant { a : nat }; + service : { get : (V) -> () } + "#; + // old input V = { a; b } needs to be <: new input V = { a } + // variant { a; b } <: variant { a } fails because field b is in old but not in new + let errors = incompatibilities(new, old); + assert!( + !errors.is_empty(), + "removing variant case from input should be incompatible" + ); + let joined = errors.join("\n"); + assert!( + joined.contains("b"), + "should mention the removed variant case 'b': {joined}" + ); +} + +// =========================================================================== +// 4. Error message quality checks +// =========================================================================== + +#[test] +fn error_message_for_missing_method_is_clear() { + let old = "service : { transfer : (nat) -> (); balance : () -> (nat) }"; + let new = "service : { balance : () -> (nat) }"; + let errors = incompatibilities(new, old); + assert_eq!(errors.len(), 1); + let msg = &errors[0]; + assert!( + msg.contains("transfer"), + "error should name the missing method: {msg}" + ); + assert!( + msg.contains("missing"), + "error should say the method is missing: {msg}" + ); +} + +#[test] +fn error_message_for_type_change_includes_types() { + let old = "service : { get : () -> (nat) }"; + let new = "service : { get : () -> (text) }"; + let errors = incompatibilities(new, old); + assert!(!errors.is_empty()); + let msg = &errors[0]; + assert!( + msg.contains("text") && msg.contains("nat"), + "error should mention both old and new types: {msg}" + ); +} + +#[test] +fn error_message_for_missing_record_field_is_clear() { + // For return types: new_ret <: old_ret. Missing non-optional field = breaking. + let old = r#" + type Res = record { name : text; age : nat }; + service : { get : () -> (Res) } + "#; + let new = r#" + type Res = record { name : text }; + service : { get : () -> (Res) } + "#; + // Return type: new_ret <: old_ret. new Res = { name } old Res = { name; age } + // record { name } <: record { name; age } => field 'age' is only in expected type and is nat (not opt) + // => FAIL + let errors = incompatibilities(new, old); + assert!(!errors.is_empty()); + let msg = &errors[0]; + assert!( + msg.contains("age"), + "error should mention the missing field: {msg}" + ); + assert!( + msg.contains("missing") || msg.contains("not optional"), + "error should explain why this is a problem: {msg}" + ); +} + +// =========================================================================== +// 5. Hierarchical report formatting +// =========================================================================== + +fn raw_incompatibilities( + new: &str, + old: &str, +) -> Vec { + service_compatibility_report(CandidSource::Text(new), CandidSource::Text(old)) + .expect("failed to load interfaces") +} + +#[test] +fn format_report_groups_by_method() { + let old = r#"service : { + transfer : (record { from : text; to : text; amount : nat }) -> (record { ok : bool; balance : nat }); + balance : () -> (nat); + audit : () -> (record { count : nat; log : text }) query; + config : () -> (record { flag : bool }); + }"#; + let new = r#"service : { + transfer : (record { from : text; to : text; amount : text }) -> (record { ok : text; balance : text }); + balance : () -> (text); + audit : () -> (record { count : text; log : nat }); + }"#; + // transfer: input field amount changed nat→text (contra: old args <: new args, nat = 6, + "expected many errors, got {}: {:?}", + errors.len(), + errors + ); + + let report = format_report(&errors); + // Verify grouping: each method name should appear exactly once as a header + let transfer_headers: Vec<_> = report + .lines() + .filter(|l| l.starts_with("method \"transfer\"")) + .collect(); + assert_eq!( + transfer_headers.len(), + 1, + "transfer should appear as a single group header, got: {:?}", + transfer_headers + ); + + // Verify nested indentation exists + assert!( + report.contains(" ") && report.contains("return type"), + "report should have indented sub-groups: {report}" + ); +} + +#[test] +fn format_report_inlines_single_leaf_errors() { + let old = "service : { get : () -> (nat) }"; + let new = "service : { get : () -> (text) }"; + let errors = raw_incompatibilities(new, old); + let report = format_report(&errors); + // A single error under a method should be inlined (no extra nesting line) + let line_count = report.lines().count(); + assert!( + line_count <= 3, + "single error should produce compact output, got {} lines:\n{report}", + line_count + ); +} + +#[test] +fn format_report_empty_for_compatible() { + let old = "service : { greet : (text) -> (text) }"; + let new = "service : { greet : (text) -> (text); extra : () -> () }"; + let errors = raw_incompatibilities(new, old); + let report = format_report(&errors); + assert!(report.is_empty(), "compatible should produce empty report"); +} diff --git a/tools/didc/src/main.rs b/tools/didc/src/main.rs index f22ce6ca8..4273a5751 100644 --- a/tools/didc/src/main.rs +++ b/tools/didc/src/main.rs @@ -191,7 +191,26 @@ fn main() -> Result<()> { if strict { subtype::equal(&mut gamma, &env, &t1, &t2)?; } else { - subtype::subtype(&mut gamma, &env, &t1, &t2)?; + let errors = + subtype::subtype_check_all(&mut gamma, &env, &t1, &t2); + if !errors.is_empty() { + let report = subtype::format_report(&errors); + eprintln!( + "{} {} incompatible change{} found:\n", + style("Error:").red().bold(), + errors.len(), + if errors.len() == 1 { "" } else { "s" } + ); + for line in report.lines() { + eprintln!(" {line}"); + } + eprintln!(); + bail!( + "new interface is not backward compatible ({} breaking change{})", + errors.len(), + if errors.len() == 1 { "" } else { "s" } + ); + } } } _ => { diff --git a/tools/ui/src/didjs/lib.rs b/tools/ui/src/didjs/lib.rs index a3a932708..afbd2800d 100644 --- a/tools/ui/src/didjs/lib.rs +++ b/tools/ui/src/didjs/lib.rs @@ -69,7 +69,17 @@ fn subtype(new: String, old: String) -> Result<(), String> { let old_actor = check_prog(&mut old_env, &old).unwrap().unwrap(); let mut gamma = std::collections::HashSet::new(); let old_actor = new_env.merge_type(old_env, old_actor); - subtype::subtype(&mut gamma, &new_env, &new_actor, &old_actor).map_err(|e| e.to_string()) + let errors = subtype::subtype_check_all(&mut gamma, &new_env, &new_actor, &old_actor); + if errors.is_empty() { + Ok(()) + } else { + let report = subtype::format_report(&errors); + Err(format!( + "{} incompatible change{} found:\n\n{report}", + errors.len(), + if errors.len() == 1 { "" } else { "s" } + )) + } } fn retrieve(path: &str) -> Option<(&str, &'static [u8])> { From a9e0827af929073a3d0535454899069e23dd0a65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sa=C5=A1a=20Tomi=C4=87?= Date: Wed, 8 Apr 2026 11:16:55 +0200 Subject: [PATCH 2/9] test: rewrite compatibility tests for better signal and coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 29 tests → 18: dropped redundant tests, folded table-driven cases - Fixed buggy test (compatible_add_variant_case_in_return tested identity) - DRY: table-driven helpers for compatible/incompatible pass/fail checks - Added missing coverage: vec element type, multi-arg functions, mixed compatible+incompatible methods, service_compatible vs report agreement, pathless errors in format_report, remove field from input record - Every test now asserts something the others don't --- rust/candid_parser/tests/compatibility.rs | 690 ++++++++++------------ 1 file changed, 320 insertions(+), 370 deletions(-) diff --git a/rust/candid_parser/tests/compatibility.rs b/rust/candid_parser/tests/compatibility.rs index 2d3b06e2a..f47001095 100644 --- a/rust/candid_parser/tests/compatibility.rs +++ b/rust/candid_parser/tests/compatibility.rs @@ -1,311 +1,300 @@ -use candid::types::subtype::format_report; +use candid::types::subtype::{format_report, Incompatibility}; use candid_parser::utils::{service_compatibility_report, service_compatible, CandidSource}; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -fn check_compatible(new: &str, old: &str) { +fn check_compatible(new: &str, old: &str, desc: &str) { service_compatible(CandidSource::Text(new), CandidSource::Text(old)) - .unwrap_or_else(|e| panic!("expected compatible, got error: {e}")); + .unwrap_or_else(|e| panic!("[{desc}] expected compatible, got error: {e}")); } -fn check_incompatible(new: &str, old: &str) { +fn check_incompatible(new: &str, old: &str, desc: &str) { assert!( service_compatible(CandidSource::Text(new), CandidSource::Text(old)).is_err(), - "expected incompatible, but check passed" + "[{desc}] expected incompatible, but check passed" ); } -fn incompatibilities(new: &str, old: &str) -> Vec { +fn get_errors(new: &str, old: &str) -> Vec { service_compatibility_report(CandidSource::Text(new), CandidSource::Text(old)) .expect("failed to load interfaces") +} + +fn error_strings(new: &str, old: &str) -> Vec { + get_errors(new, old) .into_iter() .map(|e| e.to_string()) .collect() } // =========================================================================== -// 1. Backward-compatible changes must PASS (no false positives) +// Backward-compatible changes must PASS (no false positives) // =========================================================================== #[test] -fn compatible_identical_services() { - let did = "service : { greet : (text) -> (text) }"; - check_compatible(did, did); -} - -#[test] -fn compatible_add_new_method() { - let old = "service : { greet : (text) -> (text) }"; - let new = "service : { greet : (text) -> (text); hello : () -> (text) }"; - check_compatible(new, old); -} - -#[test] -fn compatible_add_optional_record_field() { - let old = r#" - type Req = record { name : text }; - service : { greet : (Req) -> (text) } - "#; - let new = r#" - type Req = record { name : text; age : opt nat }; - service : { greet : (Req) -> (text) } - "#; - check_compatible(new, old); -} - -#[test] -fn compatible_narrow_return_nat_subtype_of_int() { - // Old returns int, new returns nat. Return types are covariant: nat <: int ✓ - let old = "service : { get : () -> (int) }"; - let new = "service : { get : () -> (nat) }"; - check_compatible(new, old); -} - -#[test] -fn compatible_add_optional_return_field() { - let old = r#" - type Res = record { id : nat }; - service : { get : () -> (Res) } - "#; - let new = r#" - type Res = record { id : nat; extra : opt text }; - service : { get : () -> (Res) } - "#; - check_compatible(new, old); -} - -#[test] -fn compatible_add_variant_case_in_return() { - // Adding a variant to a return type is safe (old callers already handle unknown variants - // via opt rule), and the new type is a subtype because the new variant's fields include - // all old fields. - let old = r#" - type Res = variant { ok : nat; err : text }; - service : { call : () -> (Res) } - "#; - let new = r#" - type Res = variant { ok : nat; err : text }; - service : { call : () -> (Res) } - "#; - check_compatible(new, old); +fn compatible_service_changes() { + let cases: &[(&str, &str, &str)] = &[ + ( + "identical service", + "service : { greet : (text) -> (text) }", + "service : { greet : (text) -> (text) }", + ), + ( + "add new method", + "service : { greet : (text) -> (text); hello : () -> (text) }", + "service : { greet : (text) -> (text) }", + ), + ( + "widen input nat→int (contravariant)", + "service : { pay : (int) -> () }", + "service : { pay : (nat) -> () }", + ), + ( + "narrow return int→nat (covariant, nat <: int)", + "service : { get : () -> (nat) }", + "service : { get : () -> (int) }", + ), + ]; + for &(desc, new, old) in cases { + check_compatible(new, old, desc); + } } #[test] -fn compatible_narrow_input_type() { - // Function inputs are contravariant: the new service can accept a *wider* input type. - // Concretely: if old expected nat, new can accept int (since nat <: int, callers sending - // nat still satisfy int). - let old = "service : { pay : (nat) -> () }"; - let new = "service : { pay : (int) -> () }"; - check_compatible(new, old); +fn compatible_record_field_changes() { + let cases: &[(&str, &str, &str)] = &[ + ( + "add opt field to input record", + "type R = record { name : text; age : opt nat }; service : { f : (R) -> () }", + "type R = record { name : text }; service : { f : (R) -> () }", + ), + ( + "add opt field to return record", + "type R = record { id : nat; extra : opt text }; service : { f : () -> (R) }", + "type R = record { id : nat }; service : { f : () -> (R) }", + ), + ( + "add null field to return record", + "service : { f : () -> (record { a : nat; b : null }) }", + "service : { f : () -> (record { a : nat }) }", + ), + ( + "add reserved field to return record", + "service : { f : () -> (record { a : nat; b : reserved }) }", + "service : { f : () -> (record { a : nat }) }", + ), + ( + "remove non-opt field from input record (extra fields in subtype are fine)", + "type R = record { name : text }; service : { f : (R) -> () }", + "type R = record { name : text; age : nat }; service : { f : (R) -> () }", + ), + ]; + for &(desc, new, old) in cases { + check_compatible(new, old, desc); + } } #[test] -fn compatible_add_reserved_field() { - let old = "service : { get : () -> (record { a : nat }) }"; - let new = "service : { get : () -> (record { a : nat; b : reserved }) }"; - check_compatible(new, old); -} - -#[test] -fn compatible_add_null_field() { - let old = "service : { get : () -> (record { a : nat }) }"; - let new = "service : { get : () -> (record { a : nat; b : null }) }"; - check_compatible(new, old); +fn compatible_variant_and_vec_changes() { + let cases: &[(&str, &str, &str)] = &[ + ( + "add variant case to input (contravariant: old callers still match)", + "type V = variant { a; b; c }; service : { f : (V) -> () }", + "type V = variant { a; b }; service : { f : (V) -> () }", + ), + ( + "identical vec types", + "service : { f : () -> (vec nat) }", + "service : { f : () -> (vec nat) }", + ), + ]; + for &(desc, new, old) in cases { + check_compatible(new, old, desc); + } } // =========================================================================== -// 2. Backward-INCOMPATIBLE changes must FAIL (no false negatives) +// Backward-INCOMPATIBLE changes must be caught (no false negatives) // =========================================================================== #[test] -fn incompatible_remove_method() { - let old = "service : { greet : (text) -> (text); hello : () -> (text) }"; - let new = "service : { greet : (text) -> (text) }"; - check_incompatible(new, old); -} - -#[test] -fn incompatible_change_return_type() { - let old = "service : { get : () -> (nat) }"; - let new = "service : { get : () -> (text) }"; - check_incompatible(new, old); -} - -#[test] -fn incompatible_change_input_type() { - let old = "service : { set : (nat) -> () }"; - let new = "service : { set : (text) -> () }"; - check_incompatible(new, old); -} - -#[test] -fn incompatible_add_required_record_field() { - let old = r#" - type Req = record { name : text }; - service : { greet : (Req) -> (text) } - "#; - let new = r#" - type Req = record { name : text; age : nat }; - service : { greet : (Req) -> (text) } - "#; - check_incompatible(new, old); -} - -#[test] -fn incompatible_remove_variant_case_in_input() { - // Removing a variant case from an input type means old callers might send - // a variant the new service doesn't understand. - let old = r#" - type Cmd = variant { start; stop; pause }; - service : { exec : (Cmd) -> () } - "#; - let new = r#" - type Cmd = variant { start; stop }; - service : { exec : (Cmd) -> () } - "#; - check_incompatible(new, old); -} - -#[test] -fn incompatible_widen_return_from_nat_to_int() { - // Old returns nat, new returns int. Return types are covariant: int <: nat is FALSE. - // Old clients expecting nat may receive negative numbers. - let old = "service : { get : () -> (nat) }"; - let new = "service : { get : () -> (int) }"; - check_incompatible(new, old); +fn incompatible_method_changes() { + let cases: &[(&str, &str, &str)] = &[ + ( + "remove method", + "service : { greet : (text) -> (text) }", + "service : { greet : (text) -> (text); hello : () -> (text) }", + ), + ( + "change func mode query→update", + "service : { get : () -> (nat) }", + "service : { get : () -> (nat) query }", + ), + ( + "change return type nat→text", + "service : { get : () -> (text) }", + "service : { get : () -> (nat) }", + ), + ( + "change input type nat→text", + "service : { set : (text) -> () }", + "service : { set : (nat) -> () }", + ), + ( + "widen return nat→int (covariant: int (int) }", + "service : { get : () -> (nat) }", + ), + ]; + for &(desc, new, old) in cases { + check_incompatible(new, old, desc); + } } #[test] -fn incompatible_change_func_mode() { - let old = "service : { get : () -> (nat) query }"; - let new = "service : { get : () -> (nat) }"; - check_incompatible(new, old); +fn incompatible_record_and_variant_changes() { + let cases: &[(&str, &str, &str)] = &[ + ( + "add required field to input record", + "type R = record { name : text; age : nat }; service : { f : (R) -> () }", + "type R = record { name : text }; service : { f : (R) -> () }", + ), + ( + "remove required field from return record", + "type R = record { name : text }; service : { f : () -> (R) }", + "type R = record { name : text; age : nat }; service : { f : () -> (R) }", + ), + ( + "remove variant case from input (old callers may send it)", + "type V = variant { start; stop }; service : { f : (V) -> () }", + "type V = variant { start; stop; pause }; service : { f : (V) -> () }", + ), + ( + "change vec element type", + "service : { f : () -> (vec text) }", + "service : { f : () -> (vec nat) }", + ), + ]; + for &(desc, new, old) in cases { + check_incompatible(new, old, desc); + } } // =========================================================================== -// 3. ALL incompatibilities are reported (not just the first) +// ALL incompatibilities collected (not just the first) // =========================================================================== #[test] -fn all_incompatible_methods_reported() { +fn collects_all_incompatible_methods() { let old = r#"service : { method_a : (nat) -> (nat); method_b : (text) -> (text); method_c : () -> (nat); + method_d : () -> (); }"#; - // Remove method_b, change method_c return type + // method_a: OK, method_b: removed, method_c: return changed, method_d: removed let new = r#"service : { method_a : (nat) -> (nat); method_c : () -> (text); }"#; - let errors = incompatibilities(new, old); - assert!( - errors.len() >= 2, - "expected at least 2 errors, got {}: {:?}", - errors.len(), - errors - ); + let errors = error_strings(new, old); + assert_eq!(errors.len(), 3, "got: {errors:?}"); let joined = errors.join("\n"); + for name in ["method_b", "method_c", "method_d"] { + assert!(joined.contains(name), "missing {name} in: {joined}"); + } assert!( - joined.contains("method_b"), - "should report missing method_b: {joined}" - ); - assert!( - joined.contains("method_c"), - "should report incompatible method_c: {joined}" + !joined.contains("method_a"), + "method_a is compatible, should not appear: {joined}" ); } #[test] -fn all_incompatible_record_fields_reported() { - let old = r#" - type Res = record { a : nat; b : text; c : bool }; - service : { get : () -> (Res) } - "#; - // Change a from nat to text, change b from text to nat (both incompatible) - let new = r#" - type Res = record { a : text; b : nat; c : bool }; - service : { get : () -> (Res) } - "#; - let errors = incompatibilities(new, old); - assert!( - errors.len() >= 2, - "expected at least 2 field errors, got {}: {:?}", - errors.len(), - errors - ); +fn collects_all_incompatible_record_fields() { + let old = "type R = record { a : nat; b : text; c : bool }; service : { f : () -> (R) }"; + let new = "type R = record { a : text; b : nat; c : bool }; service : { f : () -> (R) }"; + let errors = error_strings(new, old); + assert_eq!(errors.len(), 2, "got: {errors:?}"); let joined = errors.join("\n"); - // The record fields use hashed IDs, but for named fields the display should include the name - assert!( - joined.contains("a") && joined.contains("b"), - "should report both incompatible fields a and b: {joined}" - ); - // Field c should NOT be reported (it's unchanged) - // (We can't easily assert "not contains c" since "c" might appear in other words, - // but we can check the count) + assert!(joined.contains("record field a"), "missing field a: {joined}"); + assert!(joined.contains("record field b"), "missing field b: {joined}"); } #[test] -fn both_input_and_return_incompatibilities_reported() { +fn collects_both_input_and_return_errors() { let old = "service : { call : (nat) -> (nat) }"; - // Change input from nat to text (breaks contravariance) and output from nat to bool let new = "service : { call : (text) -> (bool) }"; - let errors = incompatibilities(new, old); - assert!( - errors.len() >= 2, - "expected at least 2 errors (input + return), got {}: {:?}", - errors.len(), - errors - ); + let errors = error_strings(new, old); + assert_eq!(errors.len(), 2, "got: {errors:?}"); let joined = errors.join("\n"); - assert!( - joined.contains("input type"), - "should report input type incompatibility: {joined}" - ); + assert!(joined.contains("input type"), "missing input error: {joined}"); assert!( joined.contains("return type"), - "should report return type incompatibility: {joined}" + "missing return error: {joined}" ); } #[test] -fn multiple_missing_methods_all_reported() { - let old = r#"service : { - alpha : () -> (); - beta : () -> (); - gamma : () -> (); - delta : () -> (); - }"#; - let new = "service : { alpha : () -> () }"; - let errors = incompatibilities(new, old); - assert_eq!( - errors.len(), - 3, - "expected 3 missing method errors, got {}: {:?}", - errors.len(), - errors - ); +fn collects_variant_field_errors() { + // Old callers may send variant cases b or c; new input must accept them + let old = "type V = variant { a; b; c }; service : { f : (V) -> () }"; + let new = "type V = variant { a }; service : { f : (V) -> () }"; + let errors = error_strings(new, old); + assert_eq!(errors.len(), 2, "got: {errors:?}"); let joined = errors.join("\n"); - assert!(joined.contains("beta"), "should report missing beta: {joined}"); + assert!(joined.contains("b"), "missing variant b: {joined}"); + assert!(joined.contains("c"), "missing variant c: {joined}"); +} + +// =========================================================================== +// Error message quality + path context +// =========================================================================== + +#[test] +fn missing_method_error_is_clear() { + let old = "service : { transfer : (nat) -> (); balance : () -> (nat) }"; + let new = "service : { balance : () -> (nat) }"; + let errors = error_strings(new, old); + assert_eq!(errors.len(), 1); + assert!(errors[0].contains("transfer"), "should name the method"); + assert!(errors[0].contains("missing"), "should say 'missing'"); +} + +#[test] +fn type_mismatch_error_names_both_types() { + let old = "service : { f : () -> (nat) }"; + let new = "service : { f : () -> (text) }"; + let errors = error_strings(new, old); + assert_eq!(errors.len(), 1); assert!( - joined.contains("gamma"), - "should report missing gamma: {joined}" + errors[0].contains("text") && errors[0].contains("nat"), + "should mention both types: {}", + errors[0] ); +} + +#[test] +fn missing_required_field_error_is_clear() { + let old = "type R = record { name : text; age : nat }; service : { f : () -> (R) }"; + let new = "type R = record { name : text }; service : { f : () -> (R) }"; + let errors = error_strings(new, old); + assert_eq!(errors.len(), 1); + let msg = &errors[0]; + assert!(msg.contains("age"), "should mention field name: {msg}"); assert!( - joined.contains("delta"), - "should report missing delta: {joined}" + msg.contains("missing") || msg.contains("not optional"), + "should explain the problem: {msg}" ); } #[test] -fn incompatibility_path_shows_nested_context() { +fn nested_path_shows_full_context() { let old = r#" type Inner = record { x : nat }; type Outer = record { inner : Inner }; @@ -316,137 +305,92 @@ fn incompatibility_path_shows_nested_context() { type Outer = record { inner : Inner }; service : { get : () -> (Outer) } "#; - let errors = incompatibilities(new, old); - assert_eq!(errors.len(), 1, "expected 1 error, got: {:?}", errors); - + let errors = error_strings(new, old); + assert_eq!(errors.len(), 1, "got: {errors:?}"); let msg = &errors[0]; - // Should show the full path: method > return type > field > field > leaf error + assert!(msg.contains("method"), "path should include method: {msg}"); assert!( - msg.contains("method") && msg.contains("return type"), - "error should include path context: {msg}" + msg.contains("return type"), + "path should include return type: {msg}" ); -} - -#[test] -fn compatible_changes_produce_no_errors() { - let old = "service : { greet : (text) -> (text) }"; - let new = "service : { greet : (text) -> (text); extra : () -> () }"; - let errors = incompatibilities(new, old); assert!( - errors.is_empty(), - "compatible change should produce no errors, got: {:?}", - errors + msg.contains("record field inner"), + "path should include outer field: {msg}" ); -} - -#[test] -fn variant_incompatibilities_all_reported() { - // New variant adds fields that don't exist in old - each is a breaking change - let old = r#" - type V = variant { a : nat; b : text }; - service : { get : (V) -> () } - "#; - // New service's input type has fewer variants than old callers might send - // (Contravariant: old input must be subtype of new input for args) - // Actually, for inputs: old_args <: new_args (contravariant) - // Old V = { a : nat; b : text }, New V = { a : nat; b : text; c : bool } - // For subtype: old_V <: new_V? Variant subtyping: all fields in old must exist in new => yes - // So ADDING variant cases to input is compatible. - // - // But REMOVING variant cases from input is NOT: - let new = r#" - type V = variant { a : nat }; - service : { get : (V) -> () } - "#; - // old input V = { a; b } needs to be <: new input V = { a } - // variant { a; b } <: variant { a } fails because field b is in old but not in new - let errors = incompatibilities(new, old); - assert!( - !errors.is_empty(), - "removing variant case from input should be incompatible" - ); - let joined = errors.join("\n"); assert!( - joined.contains("b"), - "should mention the removed variant case 'b': {joined}" + msg.contains("record field x"), + "path should include inner field: {msg}" ); } // =========================================================================== -// 4. Error message quality checks +// Multi-arg functions // =========================================================================== #[test] -fn error_message_for_missing_method_is_clear() { - let old = "service : { transfer : (nat) -> (); balance : () -> (nat) }"; - let new = "service : { balance : () -> (nat) }"; - let errors = incompatibilities(new, old); - assert_eq!(errors.len(), 1); - let msg = &errors[0]; - assert!( - msg.contains("transfer"), - "error should name the missing method: {msg}" - ); - assert!( - msg.contains("missing"), - "error should say the method is missing: {msg}" - ); +fn multi_arg_function_incompatibilities() { + let old = "service : { f : (nat, text) -> (bool, nat) }"; + let new = "service : { f : (nat, bool) -> (bool, text) }"; + // Input: old (nat, text) must <: new (nat, bool) → field 1 text (nat) }"; - let new = "service : { get : () -> (text) }"; - let errors = incompatibilities(new, old); - assert!(!errors.is_empty()); - let msg = &errors[0]; - assert!( - msg.contains("text") && msg.contains("nat"), - "error should mention both old and new types: {msg}" - ); -} +// =========================================================================== +// service_compatible and service_compatibility_report agree +// =========================================================================== #[test] -fn error_message_for_missing_record_field_is_clear() { - // For return types: new_ret <: old_ret. Missing non-optional field = breaking. - let old = r#" - type Res = record { name : text; age : nat }; - service : { get : () -> (Res) } - "#; - let new = r#" - type Res = record { name : text }; - service : { get : () -> (Res) } - "#; - // Return type: new_ret <: old_ret. new Res = { name } old Res = { name; age } - // record { name } <: record { name; age } => field 'age' is only in expected type and is nat (not opt) - // => FAIL - let errors = incompatibilities(new, old); - assert!(!errors.is_empty()); - let msg = &errors[0]; - assert!( - msg.contains("age"), - "error should mention the missing field: {msg}" - ); - assert!( - msg.contains("missing") || msg.contains("not optional"), - "error should explain why this is a problem: {msg}" - ); +fn report_and_simple_check_agree() { + let compatible = [ + ( + "service : { f : (text) -> (text); g : () -> () }", + "service : { f : (text) -> (text) }", + ), + ( + "service : { f : (int) -> () }", + "service : { f : (nat) -> () }", + ), + ]; + for (new, old) in compatible { + let report = get_errors(new, old); + assert!(report.is_empty(), "report should be empty for compatible"); + assert!( + service_compatible(CandidSource::Text(new), CandidSource::Text(old)).is_ok(), + "service_compatible should pass for compatible" + ); + } + + let incompatible = [ + ( + "service : { f : (text) -> (text) }", + "service : { f : (text) -> (text); g : () -> () }", + ), + ( + "service : { f : (nat) -> () }", + "service : { f : (int) -> () }", + ), + ]; + for (new, old) in incompatible { + let report = get_errors(new, old); + assert!(!report.is_empty(), "report should have errors"); + assert!( + service_compatible(CandidSource::Text(new), CandidSource::Text(old)).is_err(), + "service_compatible should fail" + ); + } } // =========================================================================== -// 5. Hierarchical report formatting +// Hierarchical report formatting // =========================================================================== -fn raw_incompatibilities( - new: &str, - old: &str, -) -> Vec { - service_compatibility_report(CandidSource::Text(new), CandidSource::Text(old)) - .expect("failed to load interfaces") -} - #[test] -fn format_report_groups_by_method() { +fn format_report_groups_by_method_and_nests() { let old = r#"service : { transfer : (record { from : text; to : text; amount : nat }) -> (record { ok : bool; balance : nat }); balance : () -> (nat); @@ -458,60 +402,66 @@ fn format_report_groups_by_method() { balance : () -> (text); audit : () -> (record { count : text; log : nat }); }"#; - // transfer: input field amount changed nat→text (contra: old args <: new args, nat = 6, - "expected many errors, got {}: {:?}", - errors.len(), - errors - ); + let errors = get_errors(new, old); + assert!(errors.len() >= 6, "expected many errors, got: {errors:?}"); let report = format_report(&errors); - // Verify grouping: each method name should appear exactly once as a header - let transfer_headers: Vec<_> = report - .lines() - .filter(|l| l.starts_with("method \"transfer\"")) - .collect(); - assert_eq!( - transfer_headers.len(), - 1, - "transfer should appear as a single group header, got: {:?}", - transfer_headers - ); - // Verify nested indentation exists + // Each method should appear exactly once as a group header + for method in ["transfer", "balance", "config"] { + let header_count = report + .lines() + .filter(|l| { + let trimmed = l.trim_start(); + trimmed.starts_with(&format!("method \"{method}\"")) + || trimmed.contains(&format!("method \"{method}\"")) + }) + .count(); + assert!( + header_count <= 1, + "{method} should appear at most once as header, got {header_count} in:\n{report}" + ); + } + + // Should have indented sub-groups assert!( - report.contains(" ") && report.contains("return type"), - "report should have indented sub-groups: {report}" + report.contains(" return type") || report.contains(" input type"), + "should have indented sub-groups:\n{report}" ); } #[test] -fn format_report_inlines_single_leaf_errors() { +fn format_report_inlines_single_leaf() { let old = "service : { get : () -> (nat) }"; let new = "service : { get : () -> (text) }"; - let errors = raw_incompatibilities(new, old); - let report = format_report(&errors); - // A single error under a method should be inlined (no extra nesting line) - let line_count = report.lines().count(); + let report = format_report(&get_errors(new, old)); + // Single error under method > return type should inline compactly assert!( - line_count <= 3, - "single error should produce compact output, got {} lines:\n{report}", - line_count + report.lines().count() <= 3, + "single error should be compact:\n{report}" ); } #[test] -fn format_report_empty_for_compatible() { - let old = "service : { greet : (text) -> (text) }"; - let new = "service : { greet : (text) -> (text); extra : () -> () }"; - let errors = raw_incompatibilities(new, old); +fn format_report_handles_path_and_pathless_errors() { + // Pathless errors (e.g. top-level type mismatch) should render as "- message" + let errors = vec![ + Incompatibility { + path: vec![], + message: "top-level mismatch".to_string(), + }, + Incompatibility { + path: vec!["method \"foo\"".to_string()], + message: "missing in new interface".to_string(), + }, + ]; let report = format_report(&errors); - assert!(report.is_empty(), "compatible should produce empty report"); + assert!( + report.contains("- top-level mismatch"), + "pathless error should render with bullet:\n{report}" + ); + assert!( + report.contains("method \"foo\""), + "pathed error should render:\n{report}" + ); } From 043045ffce8e72847a6ae28305ca1a4d947eb3a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sa=C5=A1a=20Tomi=C4=87?= Date: Wed, 8 Apr 2026 11:20:49 +0200 Subject: [PATCH 3/9] lint --- rust/candid/src/types/internal.rs | 1 - rust/candid/src/types/subtype.rs | 31 ++++++++++++----------- rust/candid_parser/tests/compatibility.rs | 15 ++++++++--- tools/didc/src/main.rs | 3 +-- 4 files changed, 29 insertions(+), 21 deletions(-) diff --git a/rust/candid/src/types/internal.rs b/rust/candid/src/types/internal.rs index 6e82e15cf..0677b6db9 100644 --- a/rust/candid/src/types/internal.rs +++ b/rust/candid/src/types/internal.rs @@ -16,7 +16,6 @@ pub struct TypeId { impl TypeId { pub fn of() -> Self { let name = std::any::type_name::(); - #[allow(function_casts_as_integer)] TypeId { id: TypeId::of:: as usize, name, diff --git a/rust/candid/src/types/subtype.rs b/rust/candid/src/types/subtype.rs index 6e946e099..8b673a5f9 100644 --- a/rust/candid/src/types/subtype.rs +++ b/rust/candid/src/types/subtype.rs @@ -205,6 +205,7 @@ pub fn subtype_check_all_with_config( /// Internal collecting variant of `subtype_`. Instead of short-circuiting on /// the first error, this continues through all fields/methods/args and pushes /// every incompatibility it finds into `errors`. +#[allow(clippy::too_many_arguments)] fn subtype_collect_( report: OptReport, gamma: &mut Gamma, @@ -293,21 +294,14 @@ fn subtype_collect_( (Null, Opt(_)) => (), // For opt rules we delegate to the existing subtype_ to test the condition, // since these are probes, not things that generate multiple independent errors. - (Opt(ty1), Opt(ty2)) - if subtype_(report, gamma, env, ty1, ty2, depth).is_ok() => - { - () - } + (Opt(ty1), Opt(ty2)) if subtype_(report, gamma, env, ty1, ty2, depth).is_ok() => {} (_, Opt(ty2)) if subtype_(report, gamma, env, t1, ty2, depth).is_ok() && !matches!( env.trace_type_with_depth(ty2, depth) .map(|t| t.as_ref().clone()), Ok(Null | Reserved | Opt(_)) - ) => - { - () - } + ) => {} (_, Opt(_)) => { let msg = format!("WARNING: {t1} <: {t2} due to special subtyping rules involving optional types/fields (see https://github.com/dfinity/candid/blob/c7659ca/spec/Candid.md#upgrading-and-subtyping). This means the two interfaces have diverged, which could cause data loss."); match report { @@ -411,12 +405,10 @@ fn subtype_collect_( // Check each argument directly instead of wrapping in a tuple record, // so we get clean error paths like "input argument 1" instead of "record field 0". check_func_params( - report, gamma, env, &f2.args, &f1.args, depth, path, errors, - "input", true, + report, gamma, env, &f2.args, &f1.args, depth, path, errors, "input", true, ); check_func_params( - report, gamma, env, &f1.rets, &f2.rets, depth, path, errors, - "return", false, + report, gamma, env, &f1.rets, &f2.rets, depth, path, errors, "return", false, ); } (Class(_, t), _) => { @@ -460,7 +452,14 @@ fn check_func_params( if sub_params.len() == 1 && sup_params.len() == 1 { path.push(format!("{label} type")); subtype_collect_( - report, gamma, env, &sub_params[0], &sup_params[0], depth, path, errors, + report, + gamma, + env, + &sub_params[0], + &sup_params[0], + depth, + path, + errors, ); path.pop(); } else { @@ -483,7 +482,9 @@ fn check_func_params( sub_params.len() ) }); - subtype_collect_(report, gamma, env, &sub_tuple, &sup_tuple, depth, path, errors); + subtype_collect_( + report, gamma, env, &sub_tuple, &sup_tuple, depth, path, errors, + ); path.pop(); } } diff --git a/rust/candid_parser/tests/compatibility.rs b/rust/candid_parser/tests/compatibility.rs index f47001095..913d1aabd 100644 --- a/rust/candid_parser/tests/compatibility.rs +++ b/rust/candid_parser/tests/compatibility.rs @@ -220,8 +220,14 @@ fn collects_all_incompatible_record_fields() { assert_eq!(errors.len(), 2, "got: {errors:?}"); let joined = errors.join("\n"); - assert!(joined.contains("record field a"), "missing field a: {joined}"); - assert!(joined.contains("record field b"), "missing field b: {joined}"); + assert!( + joined.contains("record field a"), + "missing field a: {joined}" + ); + assert!( + joined.contains("record field b"), + "missing field b: {joined}" + ); } #[test] @@ -232,7 +238,10 @@ fn collects_both_input_and_return_errors() { assert_eq!(errors.len(), 2, "got: {errors:?}"); let joined = errors.join("\n"); - assert!(joined.contains("input type"), "missing input error: {joined}"); + assert!( + joined.contains("input type"), + "missing input error: {joined}" + ); assert!( joined.contains("return type"), "missing return error: {joined}" diff --git a/tools/didc/src/main.rs b/tools/didc/src/main.rs index 4273a5751..5495319ac 100644 --- a/tools/didc/src/main.rs +++ b/tools/didc/src/main.rs @@ -191,8 +191,7 @@ fn main() -> Result<()> { if strict { subtype::equal(&mut gamma, &env, &t1, &t2)?; } else { - let errors = - subtype::subtype_check_all(&mut gamma, &env, &t1, &t2); + let errors = subtype::subtype_check_all(&mut gamma, &env, &t1, &t2); if !errors.is_empty() { let report = subtype::format_report(&errors); eprintln!( From ea9ed88809def1a02c3da537bf043f9d9b9aae90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sa=C5=A1a=20Tomi=C4=87?= Date: Wed, 8 Apr 2026 11:23:52 +0200 Subject: [PATCH 4/9] lint --- rust/candid/src/types/internal.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/candid/src/types/internal.rs b/rust/candid/src/types/internal.rs index 0677b6db9..e11364879 100644 --- a/rust/candid/src/types/internal.rs +++ b/rust/candid/src/types/internal.rs @@ -17,7 +17,7 @@ impl TypeId { pub fn of() -> Self { let name = std::any::type_name::(); TypeId { - id: TypeId::of:: as usize, + id: TypeId::of:: as *const () as usize, name, } } From 3caf4a9ad24715d2dd2fa5f722b34f5c4e42d5a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sa=C5=A1a=20Tomi=C4=87?= Date: Wed, 8 Apr 2026 16:12:40 +0200 Subject: [PATCH 5/9] Remove dead function --- rust/candid/src/types/subtype.rs | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/rust/candid/src/types/subtype.rs b/rust/candid/src/types/subtype.rs index 8b673a5f9..b74c193cc 100644 --- a/rust/candid/src/types/subtype.rs +++ b/rust/candid/src/types/subtype.rs @@ -180,28 +180,6 @@ pub fn subtype_check_all( errors } -/// Like [`subtype_check_all`] but with configurable opt-rule reporting. -pub fn subtype_check_all_with_config( - report: OptReport, - gamma: &mut Gamma, - env: &TypeEnv, - t1: &Type, - t2: &Type, -) -> Vec { - let mut errors = Vec::new(); - subtype_collect_( - report, - gamma, - env, - t1, - t2, - &RecursionDepth::new(), - &mut Vec::new(), - &mut errors, - ); - errors -} - /// Internal collecting variant of `subtype_`. Instead of short-circuiting on /// the first error, this continues through all fields/methods/args and pushes /// every incompatibility it finds into `errors`. From 0988039119d6920ed83ef62a1c4a96722508c48d Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Wed, 8 Apr 2026 16:01:41 -0400 Subject: [PATCH 6/9] test: add input-side message wording tests Three tests covering the contravariant (input type) path for record field missing, variant case removed, and arg-count mismatch error messages. Co-Authored-By: Claude Sonnet 4.6 --- rust/candid_parser/tests/compatibility.rs | 56 +++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/rust/candid_parser/tests/compatibility.rs b/rust/candid_parser/tests/compatibility.rs index 913d1aabd..5e25cf7d5 100644 --- a/rust/candid_parser/tests/compatibility.rs +++ b/rust/candid_parser/tests/compatibility.rs @@ -474,3 +474,59 @@ fn format_report_handles_path_and_pathless_errors() { "pathed error should render:\n{report}" ); } + +// =========================================================================== +// Input-side message wording +// =========================================================================== + +#[test] +fn input_record_required_field_added_message_names_correct_side() { + let old = "type R = record { name : text }; service : { f : (R) -> () }"; + let new = "type R = record { name : text; age : nat }; service : { f : (R) -> () }"; + let errors = error_strings(new, old); + assert_eq!( + errors.len(), + 1, + "expected exactly one error, got: {errors:?}" + ); + let msg = &errors[0]; + assert!( + !msg.contains("new type is missing"), + "message incorrectly blames the new type for the missing field: {msg}" + ); + assert!(msg.contains("age"), "message should mention the field name: {msg}"); +} + +#[test] +fn input_variant_case_removed_message_names_correct_side() { + let old = "type V = variant { a; b }; service : { f : (V) -> () }"; + let new = "type V = variant { a }; service : { f : (V) -> () }"; + let errors = error_strings(new, old); + assert_eq!( + errors.len(), + 1, + "expected exactly one error, got: {errors:?}" + ); + let msg = &errors[0]; + assert!( + !msg.contains("new variant has field"), + "message incorrectly says the new variant has the dropped case: {msg}" + ); + assert!(msg.contains("b"), "message should mention the variant case name: {msg}"); +} + +#[test] +fn input_arg_count_increase_message_has_correct_counts() { + let old = "service : { f : (nat) -> () }"; + let new = "service : { f : (nat, text) -> () }"; + let errors = error_strings(new, old); + assert!(!errors.is_empty(), "expected at least one error, got none"); + let msg = errors + .iter() + .find(|e| e.contains("arg")) + .unwrap_or_else(|| panic!("no arg-count error found in: {errors:?}")); + assert!( + msg.contains("old has 1") && msg.contains("new has 2"), + "arg-count message has wrong old/new values: {msg}" + ); +} From ed76880e83531d7313270baf8ee357d39b1a09cb Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Wed, 8 Apr 2026 16:01:46 -0400 Subject: [PATCH 7/9] fix: correct direction-inverted error messages in input type checks Thread `is_input: bool` through `subtype_collect_` so record and variant error messages correctly identify which side is missing a field/case in the contravariant (function input) checking path. Also fix the swapped old/new arg-count values in the `check_func_params` input branch. Co-Authored-By: Claude Sonnet 4.6 --- rust/candid/src/types/subtype.rs | 60 +++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/rust/candid/src/types/subtype.rs b/rust/candid/src/types/subtype.rs index b74c193cc..bf3027aff 100644 --- a/rust/candid/src/types/subtype.rs +++ b/rust/candid/src/types/subtype.rs @@ -176,6 +176,7 @@ pub fn subtype_check_all( &RecursionDepth::new(), &mut Vec::new(), &mut errors, + false, ); errors } @@ -193,6 +194,7 @@ fn subtype_collect_( depth: &RecursionDepth, path: &mut Vec, errors: &mut Vec, + is_input: bool, ) { let _guard = match depth.guard() { Ok(g) => g, @@ -224,6 +226,7 @@ fn subtype_collect_( depth, path, errors, + is_input, ), (_, Var(id)) => subtype_collect_( report, @@ -234,6 +237,7 @@ fn subtype_collect_( depth, path, errors, + is_input, ), (Knot(id), _) => subtype_collect_( report, @@ -244,6 +248,7 @@ fn subtype_collect_( depth, path, errors, + is_input, ), (_, Knot(id)) => subtype_collect_( report, @@ -254,6 +259,7 @@ fn subtype_collect_( depth, path, errors, + is_input, ), (_, _) => unreachable!(), }; @@ -267,7 +273,7 @@ fn subtype_collect_( (Empty, _) => (), (Nat, Int) => (), (Vec(ty1), Vec(ty2)) => { - subtype_collect_(report, gamma, env, ty1, ty2, depth, path, errors); + subtype_collect_(report, gamma, env, ty1, ty2, depth, path, errors, is_input); } (Null, Opt(_)) => (), // For opt rules we delegate to the existing subtype_ to test the condition, @@ -299,7 +305,9 @@ fn subtype_collect_( match fields.get(id) { Some(ty1) => { path.push(format!("record field {id}")); - subtype_collect_(report, gamma, env, ty1, ty2, depth, path, errors); + subtype_collect_( + report, gamma, env, ty1, ty2, depth, path, errors, is_input, + ); path.pop(); } None => { @@ -310,10 +318,17 @@ fn subtype_collect_( if !is_optional { errors.push(Incompatibility { path: path.clone(), - message: format!( - "new type is missing required field {id} (type {ty2}), \ - which is expected by the old type and is not optional" - ), + message: if is_input { + format!( + "new service requires field {id} (type {ty2}), \ + which old callers don't provide and is not optional" + ) + } else { + format!( + "new type is missing required field {id} (type {ty2}), \ + which is expected by the old type and is not optional" + ) + }, }); } } @@ -326,15 +341,24 @@ fn subtype_collect_( match fields.get(id) { Some(ty2) => { path.push(format!("variant field {id}")); - subtype_collect_(report, gamma, env, ty1, ty2, depth, path, errors); + subtype_collect_( + report, gamma, env, ty1, ty2, depth, path, errors, is_input, + ); path.pop(); } None => { errors.push(Incompatibility { path: path.clone(), - message: format!( - "new variant has field {id} that does not exist in the old type" - ), + message: if is_input { + format!( + "old callers may send variant case {id}, \ + which the new service no longer handles" + ) + } else { + format!( + "new variant has field {id} that does not exist in the old type" + ) + }, }); } } @@ -346,7 +370,7 @@ fn subtype_collect_( match meths.get(name) { Some(ty1) => { path.push(format!("method \"{name}\"")); - subtype_collect_(report, gamma, env, ty1, ty2, depth, path, errors); + subtype_collect_(report, gamma, env, ty1, ty2, depth, path, errors, false); path.pop(); } None => { @@ -390,10 +414,10 @@ fn subtype_collect_( ); } (Class(_, t), _) => { - subtype_collect_(report, gamma, env, t, t2, depth, path, errors); + subtype_collect_(report, gamma, env, t, t2, depth, path, errors, is_input); } (_, Class(_, t)) => { - subtype_collect_(report, gamma, env, t1, t, depth, path, errors); + subtype_collect_(report, gamma, env, t1, t, depth, path, errors, is_input); } (Unknown, _) => unreachable!(), (_, Unknown) => unreachable!(), @@ -438,6 +462,7 @@ fn check_func_params( depth, path, errors, + is_input, ); path.pop(); } else { @@ -446,11 +471,12 @@ fn check_func_params( path.push(if sub_params.len() == sup_params.len() { format!("{label} types") } else if is_input { + // sub_params = old args, sup_params = new args (contravariant swap) format!( "{label} types (old has {} arg{}, new has {})", - sup_params.len(), - if sup_params.len() == 1 { "" } else { "s" }, - sub_params.len() + sub_params.len(), + if sub_params.len() == 1 { "" } else { "s" }, + sup_params.len() ) } else { format!( @@ -461,7 +487,7 @@ fn check_func_params( ) }); subtype_collect_( - report, gamma, env, &sub_tuple, &sup_tuple, depth, path, errors, + report, gamma, env, &sub_tuple, &sup_tuple, depth, path, errors, is_input, ); path.pop(); } From cff372f1823dca7d61ed6292b6ee8a00eb37f1e2 Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Wed, 8 Apr 2026 16:02:11 -0400 Subject: [PATCH 8/9] chore: cargo fmt Co-Authored-By: Claude Sonnet 4.6 --- rust/candid_parser/tests/compatibility.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/rust/candid_parser/tests/compatibility.rs b/rust/candid_parser/tests/compatibility.rs index 5e25cf7d5..2671fe924 100644 --- a/rust/candid_parser/tests/compatibility.rs +++ b/rust/candid_parser/tests/compatibility.rs @@ -494,7 +494,10 @@ fn input_record_required_field_added_message_names_correct_side() { !msg.contains("new type is missing"), "message incorrectly blames the new type for the missing field: {msg}" ); - assert!(msg.contains("age"), "message should mention the field name: {msg}"); + assert!( + msg.contains("age"), + "message should mention the field name: {msg}" + ); } #[test] @@ -512,7 +515,10 @@ fn input_variant_case_removed_message_names_correct_side() { !msg.contains("new variant has field"), "message incorrectly says the new variant has the dropped case: {msg}" ); - assert!(msg.contains("b"), "message should mention the variant case name: {msg}"); + assert!( + msg.contains("b"), + "message should mention the variant case name: {msg}" + ); } #[test] From 2f470eed396a267fbd181735a437272b21c70c8c Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Wed, 8 Apr 2026 16:13:56 -0400 Subject: [PATCH 9/9] chore: release candid 0.10.27, candid_parser 0.3.1, didc 0.6.1 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- CHANGELOG.md | 19 +++++++++++++++++++ Cargo.lock | 16 ++++++++-------- rust/bench/Cargo.lock | 6 +++--- rust/candid/Cargo.toml | 4 ++-- rust/candid_derive/Cargo.toml | 2 +- rust/candid_parser/Cargo.toml | 2 +- tools/didc/Cargo.toml | 2 +- 7 files changed, 35 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2533fe9b1..d65e14e96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## 2026-04-08 + +### didc 0.6.1 + +* Non-breaking changes: + + `didc check` now reports **all** incompatible changes at once, grouped by method, instead of stopping at the first error + + Clearer error messages: e.g. "missing in new interface" and "function annotation changed from query to update" + +### candid_parser 0.3.1 + +* Non-breaking changes: + + Add `service_compatibility_report()` returning a full grouped compatibility report as a string + +### Candid 0.10.27 + +* Non-breaking changes: + + Add `subtype_check_all()` to collect all subtype errors in one pass (previously stopped at the first) + + Add `Incompatibility` type and `format_report()` for structured, hierarchical error reporting + ## 2026-03-18 ### Candid 0.10.26 diff --git a/Cargo.lock b/Cargo.lock index 8eb17f36e..e0a09b737 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -209,14 +209,14 @@ dependencies = [ [[package]] name = "candid" -version = "0.10.26" +version = "0.10.27" dependencies = [ "anyhow", "bincode", "binread", "byteorder", - "candid_derive 0.10.26", - "candid_parser 0.3.0", + "candid_derive 0.10.27", + "candid_parser 0.3.1", "hex", "ic_principal 0.1.2", "leb128", @@ -247,7 +247,7 @@ dependencies = [ [[package]] name = "candid_derive" -version = "0.10.26" +version = "0.10.27" dependencies = [ "lazy_static", "proc-macro2 1.0.86", @@ -276,11 +276,11 @@ dependencies = [ [[package]] name = "candid_parser" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "arbitrary", - "candid 0.10.26", + "candid 0.10.27", "codespan-reporting", "console", "convert_case", @@ -479,10 +479,10 @@ dependencies = [ [[package]] name = "didc" -version = "0.6.0" +version = "0.6.1" dependencies = [ "anyhow", - "candid_parser 0.3.0", + "candid_parser 0.3.1", "clap", "console", "hex", diff --git a/rust/bench/Cargo.lock b/rust/bench/Cargo.lock index ef54d1e25..9b15a6219 100644 --- a/rust/bench/Cargo.lock +++ b/rust/bench/Cargo.lock @@ -156,7 +156,7 @@ dependencies = [ [[package]] name = "candid" -version = "0.10.26" +version = "0.10.27" dependencies = [ "anyhow", "binread", @@ -177,7 +177,7 @@ dependencies = [ [[package]] name = "candid_derive" -version = "0.10.26" +version = "0.10.27" dependencies = [ "lazy_static", "proc-macro2", @@ -187,7 +187,7 @@ dependencies = [ [[package]] name = "candid_parser" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "candid", diff --git a/rust/candid/Cargo.toml b/rust/candid/Cargo.toml index 5655ede03..f5b0a8b4c 100644 --- a/rust/candid/Cargo.toml +++ b/rust/candid/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "candid" # sync with the version in `candid_derive/Cargo.toml` -version = "0.10.26" +version = "0.10.27" edition = "2021" rust-version.workspace = true authors = ["DFINITY Team"] @@ -16,7 +16,7 @@ keywords = ["internet-computer", "idl", "candid", "dfinity"] include = ["src", "Cargo.toml", "LICENSE", "README.md"] [dependencies] -candid_derive = { path = "../candid_derive", version = "=0.10.26" } +candid_derive = { path = "../candid_derive", version = "=0.10.27" } ic_principal = { path = "../ic_principal", version = "0.1.0" } binread = { version = "2.2", features = ["debug_template"] } byteorder = "1.5.0" diff --git a/rust/candid_derive/Cargo.toml b/rust/candid_derive/Cargo.toml index 9aee2aa3f..efd23f980 100644 --- a/rust/candid_derive/Cargo.toml +++ b/rust/candid_derive/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "candid_derive" # sync with the version in `candid/Cargo.toml` -version = "0.10.26" +version = "0.10.27" edition = "2021" rust-version.workspace = true authors = ["DFINITY Team"] diff --git a/rust/candid_parser/Cargo.toml b/rust/candid_parser/Cargo.toml index 3293440d1..dbb4edffe 100644 --- a/rust/candid_parser/Cargo.toml +++ b/rust/candid_parser/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "candid_parser" -version = "0.3.0" +version = "0.3.1" edition = "2021" rust-version.workspace = true authors = ["DFINITY Team"] diff --git a/tools/didc/Cargo.toml b/tools/didc/Cargo.toml index 52f4f9448..3eecbdd71 100644 --- a/tools/didc/Cargo.toml +++ b/tools/didc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "didc" -version = "0.6.0" +version = "0.6.1" authors = ["DFINITY Team"] edition = "2021"