diff --git a/AGENTS.md b/AGENTS.md index a29ee1c..6838a71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,6 +106,10 @@ Add an exemption only for intentional, verified runtime behavior, with a nearby Enum values and struct fields are checked bidirectionally. Enum mapping is structural: named schemas resolve to model types; properties, array items, compositions, and operation parameters resolve through their Rust field/argument type. Serde container/variant renames determine wire values. Catch-alls are recognized through `untagged`/`other` attributes, never variant names; a genuine unit variant named `Unknown` remains a value. Numeric, mixed, and scalar-backed enum constraints are reported explicitly as unsupported rather than silently skipped. +##### `VALUES` const checking + +Enums that the CLI validates against declare `pub const VALUES: &'static [&'static str]` in an `impl` block — a hand-written literal slice of the enum's non-catch-all wire values. The analyzer verifies that any enum with a `VALUES` const has it exactly equal (as a set) to its variant wire values; a mismatch produces `FindingKind::EnumValuesMismatch`. This is opt-in: enums without a `VALUES` const are not checked. When adding a new enum value to a `VALUES`-bearing enum, update both the variant and the const or CI will fail. + ##### Deprecated field hiding Every spec-deprecated request or response field belongs in `meta.rs::DEPRECATED_FIELDS` and carries `#[cfg(feature = "deprecated-fields")]` on the `models.rs` field. It is therefore absent from the public model by default. Request fields that must be gated out but resolve as required are `Option` with a documented optionality exemption. Update CLI code that directly accesses or constructs an affected model so both feature configurations compile. diff --git a/crates/clickhouse-cloud-api/src/models.rs b/crates/clickhouse-cloud-api/src/models.rs index c5c49a5..6327d82 100644 --- a/crates/clickhouse-cloud-api/src/models.rs +++ b/crates/clickhouse-cloud-api/src/models.rs @@ -30,6 +30,11 @@ impl std::fmt::Display for PgHaType { } } +impl PgHaType { + /// Wire values accepted by the API, excluding the catch-all. + pub const VALUES: &'static [&'static str] = &["none", "async", "sync"]; +} + /// `pgProvider` enum from the ClickHouse Cloud API. #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] pub enum PgProvider { @@ -50,6 +55,11 @@ impl std::fmt::Display for PgProvider { } } +impl PgProvider { + /// Wire values accepted by the API, excluding the catch-all. + pub const VALUES: &'static [&'static str] = &["aws"]; +} + /// `pgSize` enum from the ClickHouse Cloud API. #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] pub enum PgSize { @@ -380,6 +390,11 @@ impl std::fmt::Display for PgVersion { } } +impl PgVersion { + /// Wire values accepted by the API, excluding the catch-all. + pub const VALUES: &'static [&'static str] = &["18", "17"]; +} + /// Inline enum for `Activity.actorType`. #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] pub enum ActivityActortype { @@ -6324,6 +6339,11 @@ impl std::fmt::Display for ServicePatchRequestReleasechannel { } } +impl ServicePatchRequestReleasechannel { + /// Wire values accepted by the API, excluding the catch-all. + pub const VALUES: &'static [&'static str] = &["slow", "default", "fast"]; +} + /// Inline enum for `ServicePostRequest.complianceType`. #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] pub enum ServicePostRequestCompliancetype { @@ -6347,6 +6367,11 @@ impl std::fmt::Display for ServicePostRequestCompliancetype { } } +impl ServicePostRequestCompliancetype { + /// Wire values accepted by the API, excluding the catch-all. + pub const VALUES: &'static [&'static str] = &["hipaa", "pci"]; +} + /// Inline enum for `ServicePostRequest.profile`. #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] pub enum ServicePostRequestProfile { @@ -6382,6 +6407,18 @@ impl std::fmt::Display for ServicePostRequestProfile { } } +impl ServicePostRequestProfile { + /// Wire values accepted by the API, excluding the catch-all. + pub const VALUES: &'static [&'static str] = &[ + "v1-default", + "v1-highmem-xs", + "v1-highmem-s", + "v1-highmem-m", + "v1-highmem-l", + "v1-highmem-xl", + ]; +} + /// Inline enum for `ServicePostRequest.provider`. #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] pub enum ServicePostRequestProvider { @@ -6408,6 +6445,11 @@ impl std::fmt::Display for ServicePostRequestProvider { } } +impl ServicePostRequestProvider { + /// Wire values accepted by the API, excluding the catch-all. + pub const VALUES: &'static [&'static str] = &["aws", "gcp", "azure"]; +} + /// Inline enum for `ServicePostRequest.region`. #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] pub enum ServicePostRequestRegion { @@ -6497,6 +6539,36 @@ impl std::fmt::Display for ServicePostRequestRegion { } } +impl ServicePostRequestRegion { + /// Wire values accepted by the API, excluding the catch-all. + pub const VALUES: &'static [&'static str] = &[ + "ap-northeast-1", + "ap-northeast-2", + "ap-south-1", + "ap-southeast-1", + "ap-southeast-2", + "ca-central-1", + "eu-central-1", + "eu-west-1", + "eu-west-2", + "il-central-1", + "us-east-1", + "us-east-2", + "us-west-2", + "us-east1", + "us-central1", + "europe-west2", + "europe-west4", + "asia-southeast1", + "asia-northeast1", + "eastus", + "eastus2", + "westus3", + "germanywestcentral", + "centralus", + ]; +} + /// Inline enum for `ServicePostRequest.releaseChannel`. #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] pub enum ServicePostRequestReleasechannel { @@ -6523,6 +6595,11 @@ impl std::fmt::Display for ServicePostRequestReleasechannel { } } +impl ServicePostRequestReleasechannel { + /// Wire values accepted by the API, excluding the catch-all. + pub const VALUES: &'static [&'static str] = &["slow", "default", "fast"]; +} + /// Inline enum for `ServicePostRequest.tier`. #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] pub enum ServicePostRequestTier { diff --git a/crates/clickhouse-openapi-analyzer/src/compare.rs b/crates/clickhouse-openapi-analyzer/src/compare.rs index ecba202..27135de 100644 --- a/crates/clickhouse-openapi-analyzer/src/compare.rs +++ b/crates/clickhouse-openapi-analyzer/src/compare.rs @@ -438,6 +438,71 @@ fn compare_enums( .detail("key", pointer), ); } + + compare_enum_values_consts(rust, report); +} + +fn compare_enum_values_consts(rust: &RustInventory, report: &mut DriftReport) { + for (name, info) in &rust.enums { + let Some(values_const) = &info.values_const else { + continue; + }; + let rust_values = &info.values; + let missing: Vec<&String> = rust_values.difference(values_const).collect(); + let extra: Vec<&String> = values_const.difference(rust_values).collect(); + if missing.is_empty() && extra.is_empty() { + continue; + } + let mut parts = Vec::new(); + if !missing.is_empty() { + let list = missing + .iter() + .map(|v| format!("{v:?}")) + .collect::>() + .join(", "); + parts.push(format!("missing {list}")); + } + if !extra.is_empty() { + let list = extra + .iter() + .map(|v| format!("{v:?}")) + .collect::>() + .join(", "); + parts.push(format!("extra {list}")); + } + let rust_item = format!("models.rs::{name}::VALUES"); + let finding = Finding::new( + FindingKind::EnumValuesMismatch, + format!("{name}::VALUES does not match enum wire values: {}", parts.join("; ")), + ) + .at_rust(&rust_item) + .detail("enum", name); + let finding = if !missing.is_empty() { + finding.detail( + "missing", + missing + .iter() + .map(|v| v.as_str()) + .collect::>() + .join(", "), + ) + } else { + finding + }; + let finding = if !extra.is_empty() { + finding.detail( + "extra", + extra + .iter() + .map(|v| v.as_str()) + .collect::>() + .join(", "), + ) + } else { + finding + }; + report.findings.push(finding); + } } fn mapping_rust_item(mapping: &EnumMapping) -> Option { @@ -994,4 +1059,124 @@ mod tests { ); } } + + #[test] + fn enum_values_const_matching_produces_no_finding() { + let models = r#" + pub enum Color { + #[serde(rename = "red")] + Red, + #[serde(rename = "blue")] + Blue, + #[serde(untagged)] + Unknown(String), + } + impl Color { + pub const VALUES: &'static [&'static str] = &["red", "blue"]; + } + "#; + let report = analyze_fixture( + models, + serde_json::json!({"type": "string", "enum": ["red", "blue"]}), + AnalyzerConfig::default(), + ); + let mismatch = report + .findings + .iter() + .find(|f| f.kind == FindingKind::EnumValuesMismatch); + assert!( + mismatch.is_none(), + "unexpected EnumValuesMismatch: {:?}", + mismatch + ); + } + + #[test] + fn enum_values_const_missing_value_reports_mismatch() { + let models = r#" + pub enum Color { + #[serde(rename = "red")] + Red, + #[serde(rename = "blue")] + Blue, + #[serde(rename = "green")] + Green, + #[serde(untagged)] + Unknown(String), + } + impl Color { + pub const VALUES: &'static [&'static str] = &["red", "blue"]; + } + "#; + let report = analyze_fixture( + models, + serde_json::json!({"type": "string", "enum": ["red", "blue", "green"]}), + AnalyzerConfig::default(), + ); + let finding = report + .findings + .iter() + .find(|f| f.kind == FindingKind::EnumValuesMismatch) + .expect("expected EnumValuesMismatch finding"); + assert_eq!(finding.details.get("enum"), Some(&"Color".to_string())); + assert_eq!(finding.details.get("missing"), Some(&"green".to_string())); + assert!( + finding.rust_item.as_deref() == Some("models.rs::Color::VALUES"), + "unexpected rust_item: {:?}", + finding.rust_item + ); + } + + #[test] + fn enum_values_const_extra_value_reports_mismatch() { + let models = r#" + pub enum Color { + #[serde(rename = "red")] + Red, + #[serde(rename = "blue")] + Blue, + #[serde(untagged)] + Unknown(String), + } + impl Color { + pub const VALUES: &'static [&'static str] = &["red", "blue", "green"]; + } + "#; + let report = analyze_fixture( + models, + serde_json::json!({"type": "string", "enum": ["red", "blue"]}), + AnalyzerConfig::default(), + ); + let finding = report + .findings + .iter() + .find(|f| f.kind == FindingKind::EnumValuesMismatch) + .expect("expected EnumValuesMismatch finding"); + assert_eq!(finding.details.get("enum"), Some(&"Color".to_string())); + assert_eq!(finding.details.get("extra"), Some(&"green".to_string())); + } + + #[test] + fn enum_without_values_const_produces_no_mismatch_finding() { + let models = r#" + pub enum Color { + #[serde(rename = "red")] + Red, + #[serde(rename = "blue")] + Blue, + #[serde(untagged)] + Unknown(String), + } + "#; + let report = analyze_fixture( + models, + serde_json::json!({"type": "string", "enum": ["red", "blue"]}), + AnalyzerConfig::default(), + ); + let mismatch = report + .findings + .iter() + .find(|f| f.kind == FindingKind::EnumValuesMismatch); + assert!(mismatch.is_none()); + } } diff --git a/crates/clickhouse-openapi-analyzer/src/report.rs b/crates/clickhouse-openapi-analyzer/src/report.rs index fc3ca4d..d713895 100644 --- a/crates/clickhouse-openapi-analyzer/src/report.rs +++ b/crates/clickhouse-openapi-analyzer/src/report.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; -pub const REPORT_SCHEMA_VERSION: u32 = 1; +pub const REPORT_SCHEMA_VERSION: u32 = 2; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -22,6 +22,7 @@ pub enum FindingKind { StrayDeprecatedMarker, MissingEnumValue, ExtraEnumValue, + EnumValuesMismatch, SnapshotAddedOperation, SnapshotRemovedOperation, SnapshotAddedSchema, diff --git a/crates/clickhouse-openapi-analyzer/src/rust_inventory.rs b/crates/clickhouse-openapi-analyzer/src/rust_inventory.rs index 62ba59b..1850ef1 100644 --- a/crates/clickhouse-openapi-analyzer/src/rust_inventory.rs +++ b/crates/clickhouse-openapi-analyzer/src/rust_inventory.rs @@ -100,6 +100,7 @@ pub(crate) struct StructInfo { pub(crate) struct EnumInfo { pub(crate) values: BTreeSet, pub(crate) is_value_enum: bool, + pub(crate) values_const: Option>, } #[derive(Debug, Clone)] @@ -240,6 +241,7 @@ impl RustInventory { EnumInfo { values, is_value_enum, + values_const: None, }, ); } @@ -251,6 +253,37 @@ impl RustInventory { _ => {} } } + // Second pass: impl blocks may lexically precede their enum declaration. + for item in &file.items { + let Item::Impl(item_impl) = item else { + continue; + }; + if item_impl.trait_.is_some() { + continue; + } + let Type::Path(self_type) = item_impl.self_ty.as_ref() else { + continue; + }; + let target = self_type + .path + .segments + .last() + .map(|segment| segment.ident.unraw().to_string()); + let Some(name) = target else { + continue; + }; + let Some(enum_info) = self.enums.get_mut(&name) else { + continue; + }; + for impl_item in &item_impl.items { + let ImplItem::Const(const_item) = impl_item else { + continue; + }; + if const_item.ident.unraw() == "VALUES" { + enum_info.values_const = Some(string_array(&const_item.expr)); + } + } + } Ok(()) } @@ -502,6 +535,89 @@ mod tests { ); } + #[test] + fn inventories_values_const_from_impl_block() { + let models = r#" + pub enum Color { + #[serde(rename = "red")] + Red, + #[serde(rename = "blue")] + Blue, + #[serde(untagged)] + Unknown(String), + } + impl Color { + pub const VALUES: &'static [&'static str] = &["red", "blue"]; + } + "#; + let inventory = RustInventory::parse("", models, "").unwrap(); + assert_eq!( + inventory.enums["Color"].values_const, + Some(BTreeSet::from(["red".to_string(), "blue".to_string()])) + ); + } + + #[test] + fn values_const_absent_when_not_declared() { + let models = r#" + pub enum State { Ready, #[serde(other)] Unknown } + "#; + let inventory = RustInventory::parse("", models, "").unwrap(); + assert!(inventory.enums["State"].values_const.is_none()); + } + + #[test] + fn values_const_ignored_for_non_enum_impl() { + let models = r#" + pub struct Widget { pub name: String } + impl Widget { + pub const VALUES: &'static [&'static str] = &["a"]; + } + "#; + let inventory = RustInventory::parse("", models, "").unwrap(); + assert!(inventory.structs.contains_key("Widget")); + assert!(inventory.enums.values().all(|e| e.values_const.is_none())); + } + + #[test] + fn inventories_values_const_when_impl_precedes_enum() { + let models = r#" + impl Color { + pub const VALUES: &'static [&'static str] = &["red", "blue"]; + } + pub enum Color { + #[serde(rename = "red")] + Red, + #[serde(rename = "blue")] + Blue, + #[serde(untagged)] + Unknown(String), + } + "#; + let inventory = RustInventory::parse("", models, "").unwrap(); + assert_eq!( + inventory.enums["Color"].values_const, + Some(BTreeSet::from(["red".to_string(), "blue".to_string()])) + ); + } + + #[test] + fn values_const_in_trait_impl_is_ignored() { + let models = r#" + pub enum Color { + #[serde(rename = "red")] + Red, + #[serde(other)] + Unknown, + } + impl Palette for Color { + const VALUES: &'static [&'static str] = &["stale"]; + } + "#; + let inventory = RustInventory::parse("", models, "").unwrap(); + assert!(inventory.enums["Color"].values_const.is_none()); + } + #[test] fn rejects_rename_all_on_struct() { let models = r#" diff --git a/crates/clickhousectl/src/cloud/commands.rs b/crates/clickhousectl/src/cloud/commands.rs index 1e68579..b87aa5f 100644 --- a/crates/clickhousectl/src/cloud/commands.rs +++ b/crates/clickhousectl/src/cloud/commands.rs @@ -18,51 +18,6 @@ use clickhouse_cloud_api::models::{ use std::io::{IsTerminal, Write}; use tabled::{Table, Tabled, settings::Style}; -/// Known provider values for client-side validation (from OpenAPI spec). -const KNOWN_PROVIDERS: &[&str] = &["aws", "gcp", "azure"]; - -/// Known region values for client-side validation (from OpenAPI spec). -const KNOWN_REGIONS: &[&str] = &[ - "ap-northeast-1", - "ap-northeast-2", - "ap-south-1", - "ap-southeast-1", - "ap-southeast-2", - "eu-central-1", - "eu-west-1", - "eu-west-2", - "il-central-1", - "us-east-1", - "us-east-2", - "us-west-2", - "us-east1", - "us-central1", - "europe-west4", - "asia-southeast1", - "asia-northeast1", - "eastus", - "eastus2", - "westus3", - "germanywestcentral", - "centralus", -]; - -/// Known release channel values for client-side validation (from OpenAPI spec). -const KNOWN_RELEASE_CHANNELS: &[&str] = &["slow", "default", "fast"]; - -/// Known compliance type values for client-side validation (from OpenAPI spec). -const KNOWN_COMPLIANCE_TYPES: &[&str] = &["hipaa", "pci"]; - -/// Known service profile values for client-side validation (from OpenAPI spec). -const KNOWN_PROFILES: &[&str] = &[ - "v1-default", - "v1-highmem-xs", - "v1-highmem-s", - "v1-highmem-m", - "v1-highmem-l", - "v1-highmem-xl", -]; - /// Resolve org ID from explicit arg or auto-detect pub(super) async fn resolve_org_id( client: &CloudClient, @@ -644,12 +599,12 @@ fn build_create_service_request( provider: parse_serde_enum::( &opts.provider, "provider", - KNOWN_PROVIDERS, + ServicePostRequestProvider::VALUES, )?, region: parse_serde_enum::( &opts.region, "region", - KNOWN_REGIONS, + ServicePostRequestRegion::VALUES, )?, ip_access_list, min_replica_memory_gb: opts.min_replica_memory_gb.map(f64::from), @@ -667,7 +622,7 @@ fn build_create_service_request( Some(value) => Some(parse_serde_enum::( value, "release_channel", - KNOWN_RELEASE_CHANNELS, + ServicePostRequestReleasechannel::VALUES, )?), None => None, }, @@ -681,7 +636,7 @@ fn build_create_service_request( Some(value) => Some(parse_serde_enum::( value, "compliance_type", - KNOWN_COMPLIANCE_TYPES, + ServicePostRequestCompliancetype::VALUES, )?), None => None, }, @@ -689,7 +644,7 @@ fn build_create_service_request( Some(value) => Some(parse_serde_enum::( value, "profile", - KNOWN_PROFILES, + ServicePostRequestProfile::VALUES, )?), None => None, }, @@ -735,7 +690,7 @@ fn build_update_service_request( parse_serde_enum::( value, "release_channel", - KNOWN_RELEASE_CHANNELS, + ServicePatchRequestReleasechannel::VALUES, ) }) .transpose()?, diff --git a/crates/clickhousectl/src/cloud/postgres.rs b/crates/clickhousectl/src/cloud/postgres.rs index 6280856..d5965af 100644 --- a/crates/clickhousectl/src/cloud/postgres.rs +++ b/crates/clickhousectl/src/cloud/postgres.rs @@ -13,10 +13,6 @@ use serde::de::DeserializeOwned; use std::path::{Path, PathBuf}; use tabled::{Table, Tabled, settings::Style}; -const KNOWN_PG_PROVIDERS: &[&str] = &["aws"]; -const KNOWN_PG_VERSIONS: &[&str] = &["18", "17"]; -const KNOWN_PG_HA_TYPES: &[&str] = &["none", "async", "sync"]; - #[derive(Subcommand)] pub enum PostgresCommands { /// List Postgres services in the organization @@ -50,10 +46,10 @@ pub enum PostgresCommands { #[arg(long, default_value = "aws")] provider: String, /// Postgres major version - #[arg(long, value_parser = clap::builder::PossibleValuesParser::new(KNOWN_PG_VERSIONS))] + #[arg(long, value_parser = clap::builder::PossibleValuesParser::new(PgVersion::VALUES))] pg_version: Option, /// High-availability type - #[arg(long, value_parser = clap::builder::PossibleValuesParser::new(KNOWN_PG_HA_TYPES))] + #[arg(long, value_parser = clap::builder::PossibleValuesParser::new(PgHaType::VALUES))] ha_type: Option, /// Resource tag (repeatable), e.g. --tag env=prod #[arg(long)] @@ -73,7 +69,7 @@ pub enum PostgresCommands { postgres_id: String, #[arg(long)] size: Option, - #[arg(long, value_parser = clap::builder::PossibleValuesParser::new(KNOWN_PG_HA_TYPES))] + #[arg(long, value_parser = clap::builder::PossibleValuesParser::new(PgHaType::VALUES))] ha_type: Option, /// Add a tag (repeatable), e.g. --add-tag env=prod #[arg(long)] @@ -554,15 +550,15 @@ pub async fn postgres_create( ) -> Result<(), Box> { let org_id = resolve_org_id(client, opts.org_id).await?; - let provider: PgProvider = parse_serde_enum(opts.provider, "provider", KNOWN_PG_PROVIDERS)?; + let provider: PgProvider = parse_serde_enum(opts.provider, "provider", PgProvider::VALUES)?; let size = parse_pg_size(opts.size)?; let pg_version: Option = opts .pg_version - .map(|v| parse_serde_enum(v, "pg-version", KNOWN_PG_VERSIONS)) + .map(|v| parse_serde_enum(v, "pg-version", PgVersion::VALUES)) .transpose()?; let ha_type: Option = opts .ha_type - .map(|v| parse_serde_enum(v, "ha-type", KNOWN_PG_HA_TYPES)) + .map(|v| parse_serde_enum(v, "ha-type", PgHaType::VALUES)) .transpose()?; let tags = parse_tags(opts.tags)?; let pg_config = opts @@ -621,7 +617,7 @@ pub async fn postgres_update( let size = opts.size.map(parse_pg_size).transpose()?; let ha_type = opts .ha_type - .map(|v| parse_serde_enum::(v, "ha-type", KNOWN_PG_HA_TYPES)) + .map(|v| parse_serde_enum::(v, "ha-type", PgHaType::VALUES)) .transpose()?; // Merge tag add/remove against current tags if any tag changes requested. diff --git a/scripts/check-openapi-drift.py b/scripts/check-openapi-drift.py index eff8b59..a3dc327 100755 --- a/scripts/check-openapi-drift.py +++ b/scripts/check-openapi-drift.py @@ -93,7 +93,7 @@ def run_analyzer(spec: dict) -> dict: report = json.loads(result.stdout) except json.JSONDecodeError as error: raise RuntimeError("OpenAPI analyzer emitted invalid JSON") from error - if report.get("schema_version") != 1: + if report.get("schema_version") != 2: raise RuntimeError( f"Unsupported DriftReport schema version: {report.get('schema_version')!r}" ) @@ -193,6 +193,7 @@ def total(*kinds): f"| Extra struct fields | {counts['extra_struct_field']} |", f"| Missing enum values | {counts['missing_enum_value']} |", f"| Extra enum values | {counts['extra_enum_value']} |", + f"| Enum VALUES const mismatches | {counts['enum_values_mismatch']} |", f"| Field optionality mismatches | {counts['field_optionality_mismatch']} |", f"| Beta status changes | {total('newly_beta_operation', 'graduated_beta_operation')} |", f"| Deprecated-field changes | {total('newly_deprecated_field', 'undeprecated_field', 'missing_deprecated_marker', 'stray_deprecated_marker')} |", @@ -238,6 +239,7 @@ def total(*kinds): ("extra_struct_field", "Extra Struct Fields"), ("missing_enum_value", "Missing Enum Values"), ("extra_enum_value", "Extra Enum Values"), + ("enum_values_mismatch", "Enum VALUES Const Mismatches"), ("field_optionality_mismatch", "Field Optionality Mismatches"), ("newly_beta_operation", "Newly Beta Operations"), ("graduated_beta_operation", "Graduated Beta Operations"), diff --git a/scripts/tests/test_check_openapi_drift.py b/scripts/tests/test_check_openapi_drift.py index 3cca4f7..8761a94 100644 --- a/scripts/tests/test_check_openapi_drift.py +++ b/scripts/tests/test_check_openapi_drift.py @@ -16,7 +16,7 @@ class DriftScriptTests(unittest.TestCase): def test_groups_findings_and_renders_spec_snippets(self): report = { - "schema_version": 1, + "schema_version": 2, "findings": [ { "kind": "missing_client_method", @@ -30,7 +30,16 @@ def test_groups_findings_and_renders_spec_snippets(self): "path": "/widgets", "summary": "List widgets", }, - } + }, + { + "kind": "enum_values_mismatch", + "message": "Color::VALUES does not match enum wire values: missing \"green\"", + "rust_item": "models.rs::Color::VALUES", + "details": { + "enum": "Color", + "missing": "green", + }, + }, ], "unsupported_enum_constraints": [ { @@ -57,6 +66,7 @@ def test_groups_findings_and_renders_spec_snippets(self): self.assertIn("## Missing Client Methods", body) self.assertIn('"operationId": "listWidgets"', body) self.assertIn("## Acknowledged Unsupported Enum Constraints", body) + self.assertIn("## Enum VALUES Const Mismatches", body) @mock.patch.object(drift.subprocess, "run") def test_analyzer_subprocess_failure_is_fatal(self, run): @@ -68,7 +78,7 @@ def test_analyzer_subprocess_failure_is_fatal(self, run): def test_analyzer_report_schema_is_validated(self, run): run.return_value = SimpleNamespace( returncode=0, - stdout=json.dumps({"schema_version": 2}), + stdout=json.dumps({"schema_version": 3}), stderr="", ) with self.assertRaisesRegex(RuntimeError, "schema version"):