Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` with a documented optionality exemption. Update CLI code that directly accesses or constructs an affected model so both feature configurations compile.
Expand Down
77 changes: 77 additions & 0 deletions crates/clickhouse-cloud-api/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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",
];
Comment thread
sdairs marked this conversation as resolved.
}

/// Inline enum for `ServicePostRequest.releaseChannel`.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub enum ServicePostRequestReleasechannel {
Expand All @@ -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 {
Expand Down
185 changes: 185 additions & 0 deletions crates/clickhouse-openapi-analyzer/src/compare.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>()
.join(", ");
parts.push(format!("missing {list}"));
}
if !extra.is_empty() {
let list = extra
.iter()
.map(|v| format!("{v:?}"))
.collect::<Vec<_>>()
.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::<Vec<_>>()
.join(", "),
)
} else {
finding
};
let finding = if !extra.is_empty() {
finding.detail(
"extra",
extra
.iter()
.map(|v| v.as_str())
.collect::<Vec<_>>()
.join(", "),
)
} else {
finding
};
report.findings.push(finding);
}
}

fn mapping_rust_item(mapping: &EnumMapping) -> Option<String> {
Expand Down Expand Up @@ -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());
}
}
3 changes: 2 additions & 1 deletion crates/clickhouse-openapi-analyzer/src/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand All @@ -22,6 +22,7 @@ pub enum FindingKind {
StrayDeprecatedMarker,
MissingEnumValue,
ExtraEnumValue,
EnumValuesMismatch,
SnapshotAddedOperation,
SnapshotRemovedOperation,
SnapshotAddedSchema,
Expand Down
Loading