diff --git a/AGENTS.md b/AGENTS.md index f2b6a091..bbbf8e36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,7 +104,7 @@ Scope enforcement to the response tree, never to "every model type": operation-u ClickHouse Cloud OpenAPI spec: https://api.clickhouse.cloud/v1 - `.github/workflows/openapi-drift.yml` runs `scripts/check-openapi-drift.py` daily. Python owns fetching, issue rendering, and GitHub orchestration only; `python3 scripts/check-openapi-drift.py --dry-run` reproduces the rendered issue without creating one. -- `crates/clickhouse-openapi-analyzer` is the single implementation of parsing and comparison. `rust_inventory.rs` parses `client.rs`, `models.rs`, and `meta.rs` with `syn`; `openapi.rs` inventories the target spec and vendored snapshot; `compare.rs` maps them and emits typed findings; `config.rs` owns ClickHouse-specific policy; `report.rs` defines the stable JSON/text report; `main.rs` is the executable used by Python. Do not duplicate source parsing, exemptions, or comparison logic in tests or Python. +- `crates/clickhouse-openapi-analyzer` is the single implementation of parsing and comparison. `rust_inventory.rs` walks and parses the module trees rooted at `client.rs`, `models.rs`, and `meta.rs` with `syn`; module cfg evaluation uses the analyzer host target, excludes `test`, treats feature-gated API as enabled, and conservatively retains unknown custom cfgs. `openapi.rs` inventories the target spec and vendored snapshot; `compare.rs` maps them and emits typed findings; `config.rs` owns ClickHouse-specific policy; `report.rs` defines the stable JSON/text report; `main.rs` is the executable used by Python. Do not duplicate source parsing, exemptions, or comparison logic in tests or Python. - The analyzer is private (`publish = false`) and a dev dependency of `clickhouse-cloud-api`. Parser/tooling dependencies such as `syn` must not enter either published crate's normal dependency graph. - `crates/clickhouse-cloud-api/tests/spec_coverage_test.rs` analyzes the vendored snapshot; its ignored test analyzes the live spec. Both and the scheduled workflow call the same analyzer and must agree. diff --git a/crates/clickhouse-cloud-api/tests/models_test.rs b/crates/clickhouse-cloud-api/tests/models_test.rs index bdaf3296..b050d64e 100644 --- a/crates/clickhouse-cloud-api/tests/models_test.rs +++ b/crates/clickhouse-cloud-api/tests/models_test.rs @@ -27,9 +27,9 @@ where /// declare `["webhook", "email"]`), so a variant defaulting its discriminator to /// another variant's value would silently retype the value on the next /// deserialize. The covered list is enforced structurally, not by convention: -/// it must equal the set of hand-written `impl Default for` blocks in -/// `models.rs` (via the analyzer's `model_types_with_manual_default_impl`), so -/// a new union gaining a `Default` without a list entry fails this test. +/// it must equal the set of hand-written `impl Default for` blocks in the model +/// module tree (via the analyzer's `model_types_with_manual_default_impl`), so a +/// new union gaining a `Default` without a list entry fails this test. #[test] fn discriminated_union_defaults_round_trip_to_the_same_variant() { let mut covered: Vec<&str> = Vec::new(); @@ -73,7 +73,7 @@ fn discriminated_union_defaults_round_trip_to_the_same_variant() { covered.sort_unstable(); let manual_default_impls = clickhouse_openapi_analyzer::model_types_with_manual_default_impl( - include_str!("../src/models.rs"), + std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")), ) .unwrap(); assert_eq!( @@ -82,7 +82,7 @@ fn discriminated_union_defaults_round_trip_to_the_same_variant() { .iter() .map(String::as_str) .collect::>(), - "the covered list must equal the manual `impl Default for` blocks in models.rs" + "the covered list must equal the manual `impl Default for` blocks in the model tree" ); } diff --git a/crates/clickhouse-cloud-api/tests/spec_coverage_test.rs b/crates/clickhouse-cloud-api/tests/spec_coverage_test.rs index 2bd8e067..6c187392 100644 --- a/crates/clickhouse-cloud-api/tests/spec_coverage_test.rs +++ b/crates/clickhouse-cloud-api/tests/spec_coverage_test.rs @@ -1,15 +1,14 @@ use std::collections::BTreeSet; +use std::path::Path; use clickhouse_openapi_analyzer::config::clickhouse_cloud_config; use clickhouse_openapi_analyzer::{ AnalysisInput, analyze, integer_model_fields_typed_as_float, model_fields_with_serde_default, - response_tree, + model_types, response_tree, }; const SPEC_JSON: &str = include_str!("../clickhouse_cloud_openapi.json"); -const CLIENT_RS: &str = include_str!("../src/client.rs"); -const MODELS_RS: &str = include_str!("../src/models.rs"); -const META_RS: &str = include_str!("../src/meta.rs"); +const RUST_SOURCE_ROOT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/src"); const LIVE_SPEC_URL: &str = "https://api.clickhouse.cloud/v1"; #[test] @@ -52,7 +51,7 @@ const ALL_OPTION_EXCEPTIONS: &[(&str, &str)] = &[]; /// would itself create `FieldOptionalityMismatch` drift. #[test] fn every_response_tree_field_is_option() { - let tree = response_tree(CLIENT_RS, MODELS_RS).unwrap(); + let tree = response_tree(Path::new(RUST_SOURCE_ROOT)).unwrap(); assert!( tree.types.len() >= 300, "vacuous test: the response tree collapsed to {} types — did client.rs \ @@ -79,7 +78,7 @@ fn every_response_tree_field_is_option() { /// `#[serde(skip_serializing_if = "Option::is_none")]`. #[test] fn every_response_tree_option_field_omits_none_when_serialized() { - let tree = response_tree(CLIENT_RS, MODELS_RS).unwrap(); + let tree = response_tree(Path::new(RUST_SOURCE_ROOT)).unwrap(); assert!( !tree.types.is_empty(), "vacuous test: the response tree is empty" @@ -100,8 +99,7 @@ fn every_response_tree_option_field_omits_none_when_serialized() { fn integer_schema_fields_are_not_typed_as_float() { let offenders = integer_model_fields_typed_as_float( SPEC_JSON, - CLIENT_RS, - MODELS_RS, + Path::new(RUST_SOURCE_ROOT), &clickhouse_cloud_config(), ) .unwrap(); @@ -111,15 +109,15 @@ fn integer_schema_fields_are_not_typed_as_float() { ); } -/// `#[serde(default)]` is banned in `models.rs`. On a required request field it -/// fabricates a value (`""`/`0`/`false`) indistinguishable from a genuine +/// `#[serde(default)]` is banned in the model module tree. On a required request +/// field it fabricates a value (`""`/`0`/`false`) indistinguishable from a genuine /// server-sent one — a consumer doing get → tweak → post would silently persist /// it (the write-back hazard that sank the superseded issue-312 policy). On /// response fields it is dead weight: every response-tree field is `Option` /// (enforced above), where a missing key already deserializes to `None`. #[test] fn models_carry_no_serde_default() { - let offenders = model_fields_with_serde_default(MODELS_RS).unwrap(); + let offenders = model_fields_with_serde_default(Path::new(RUST_SOURCE_ROOT)).unwrap(); assert!( offenders.is_empty(), "remove #[serde(default)] from: {offenders:?}" @@ -136,11 +134,17 @@ fn models_carry_no_serde_default() { /// they return into all-`Option` `{Name}Response` variants before wiring them up. #[test] fn scim_models_are_outside_the_response_tree() { + let model_types = model_types(Path::new(RUST_SOURCE_ROOT)).unwrap(); + let scim_model_types = model_types + .iter() + .filter(|name| name.starts_with("Scim")) + .collect::>(); assert!( - MODELS_RS.matches("\npub struct Scim").count() >= 30, - "vacuous test: the SCIM model family is no longer named `Scim*`" + scim_model_types.len() >= 40, + "vacuous test: the SCIM model family collapsed to {} types", + scim_model_types.len() ); - let tree = response_tree(CLIENT_RS, MODELS_RS).unwrap(); + let tree = response_tree(Path::new(RUST_SOURCE_ROOT)).unwrap(); let scim_response_types = tree .types .iter() @@ -179,9 +183,7 @@ fn analyze_spec( AnalysisInput { spec_json, snapshot_json: SPEC_JSON, - client_rs: CLIENT_RS, - models_rs: MODELS_RS, - meta_rs: META_RS, + rust_source_root: Path::new(RUST_SOURCE_ROOT), }, config, ) diff --git a/crates/clickhouse-openapi-analyzer/build.rs b/crates/clickhouse-openapi-analyzer/build.rs new file mode 100644 index 00000000..e76b67d6 --- /dev/null +++ b/crates/clickhouse-openapi-analyzer/build.rs @@ -0,0 +1,12 @@ +fn main() { + for (cargo_var, rust_var) in [ + ("CARGO_CFG_TARGET_ENV", "ANALYZER_TARGET_ENV"), + ("CARGO_CFG_TARGET_VENDOR", "ANALYZER_TARGET_VENDOR"), + ] { + println!("cargo::rerun-if-env-changed={cargo_var}"); + println!( + "cargo::rustc-env={rust_var}={}", + std::env::var(cargo_var).expect("Cargo must provide target cfg values") + ); + } +} diff --git a/crates/clickhouse-openapi-analyzer/src/lib.rs b/crates/clickhouse-openapi-analyzer/src/lib.rs index 790b1cff..fb6e7788 100644 --- a/crates/clickhouse-openapi-analyzer/src/lib.rs +++ b/crates/clickhouse-openapi-analyzer/src/lib.rs @@ -26,6 +26,7 @@ pub mod config; pub mod report; use std::collections::BTreeSet; +use std::path::Path; use config::AnalyzerConfig; use openapi::OpenApiInventory; @@ -37,9 +38,9 @@ use thiserror::Error; pub struct AnalysisInput<'a> { pub spec_json: &'a str, pub snapshot_json: &'a str, - pub client_rs: &'a str, - pub models_rs: &'a str, - pub meta_rs: &'a str, + /// Directory containing the `client`, `models`, and `meta` root modules. + /// Both `.rs` and `/mod.rs` facade layouts are supported. + pub rust_source_root: &'a Path, } #[derive(Debug, Error)] @@ -48,11 +49,10 @@ pub enum AnalyzeError { SpecJson(#[source] serde_json::Error), #[error("failed to parse snapshot OpenAPI JSON: {0}")] SnapshotJson(#[source] serde_json::Error), - // Covers both syn parse failures and analyzer policy rejections (e.g. a banned - // `rename_all`), which both surface as a `syn::Error`; the source message is - // self-explanatory, so the wrapper text stays neutral rather than claiming a parse failure. - #[error("invalid Rust source: {0}")] - RustSource(#[source] syn::Error), + // Covers module loading, syn parse failures, and source-policy rejections + // such as a banned `rename_all`. + #[error("invalid Rust source tree: {0}")] + RustSource(String), #[error("invalid target OpenAPI document: {0}")] SpecInventory(String), #[error("invalid snapshot OpenAPI document: {0}")] @@ -65,8 +65,7 @@ pub fn analyze( ) -> Result { let spec = serde_json::from_str(input.spec_json).map_err(AnalyzeError::SpecJson)?; let snapshot = serde_json::from_str(input.snapshot_json).map_err(AnalyzeError::SnapshotJson)?; - let rust = RustInventory::parse(input.client_rs, input.models_rs, input.meta_rs) - .map_err(AnalyzeError::RustSource)?; + let rust = load_rust_inventory(input.rust_source_root)?; let spec = OpenApiInventory::build(&spec, config).map_err(AnalyzeError::SpecInventory)?; let snapshot = OpenApiInventory::build(&snapshot, config).map_err(AnalyzeError::SnapshotInventory)?; @@ -93,10 +92,10 @@ pub struct ResponseTree { pub option_fields_missing_skip_serializing_if: BTreeSet<(String, String)>, } -/// Computes response-tree membership from the library's `client.rs` and -/// `models.rs` sources. -pub fn response_tree(client_rs: &str, models_rs: &str) -> Result { - let rust = RustInventory::parse(client_rs, models_rs, "").map_err(AnalyzeError::RustSource)?; +/// Computes response-tree membership from the library's client and model module +/// trees. +pub fn response_tree(rust_source_root: &Path) -> Result { + let rust = load_rust_inventory(rust_source_root)?; let types = rust.response_reachable_types(); let mut non_option_fields = BTreeSet::new(); let mut option_fields_missing_skip_serializing_if = BTreeSet::new(); @@ -128,13 +127,12 @@ pub fn response_tree(client_rs: &str, models_rs: &str) -> Result Result, AnalyzeError> { let spec = serde_json::from_str(spec_json).map_err(AnalyzeError::SpecJson)?; let spec = OpenApiInventory::build(&spec, config).map_err(AnalyzeError::SpecInventory)?; - let rust = RustInventory::parse(client_rs, models_rs, "").map_err(AnalyzeError::RustSource)?; + let rust = load_rust_inventory(rust_source_root)?; let response_types = rust.response_reachable_types(); let mut offenders = BTreeSet::new(); @@ -162,7 +160,7 @@ pub fn integer_model_fields_typed_as_float( Ok(offenders) } -/// Lists every public model struct field in `models_rs` that carries a +/// Lists every public model struct field in the model module tree that carries a /// field-level `#[serde(default)]` (a container-level one reports every field /// of its struct), as `StructName.rust_field_name`. /// @@ -176,12 +174,19 @@ pub fn integer_model_fields_typed_as_float( /// because it compares Rust source against a repository policy rather than /// against the OpenAPI spec. The parsing stays behind this narrow function so /// `syn` never enters the `clickhouse-cloud-api` dependency graph. -pub fn model_fields_with_serde_default(models_rs: &str) -> Result, AnalyzeError> { - rust_inventory::model_fields_with_serde_default(models_rs).map_err(AnalyzeError::RustSource) +pub fn model_fields_with_serde_default( + rust_source_root: &Path, +) -> Result, AnalyzeError> { + Ok(load_rust_inventory(rust_source_root)?.model_fields_with_serde_default()) } -/// Lists every model type in `models_rs` with a hand-written `impl Default -/// for` block, sorted by name. +/// Lists every public struct, enum, and type alias in the model module tree. +pub fn model_types(rust_source_root: &Path) -> Result, AnalyzeError> { + Ok(load_rust_inventory(rust_source_root)?.model_types) +} + +/// Lists every model type in the model module tree with a hand-written `impl +/// Default for` block, sorted by name. /// /// Backs the completeness half of /// `discriminated_union_defaults_round_trip_to_the_same_variant` in @@ -193,14 +198,32 @@ pub fn model_fields_with_serde_default(models_rs: &str) -> Result, A /// [`model_fields_with_serde_default`], this is a repository-policy check /// rather than a drift `FindingKind`, and it keeps `syn` out of the published /// crate's dependency graph. -pub fn model_types_with_manual_default_impl(models_rs: &str) -> Result, AnalyzeError> { - rust_inventory::model_types_with_manual_default_impl(models_rs) - .map_err(AnalyzeError::RustSource) +pub fn model_types_with_manual_default_impl( + rust_source_root: &Path, +) -> Result, AnalyzeError> { + Ok(load_rust_inventory(rust_source_root)? + .manual_default_impls + .into_iter() + .collect()) +} + +fn load_rust_inventory(rust_source_root: &Path) -> Result { + RustInventory::load(rust_source_root) + .map_err(|error| AnalyzeError::RustSource(error.to_string())) } #[cfg(test)] mod tests { use super::*; + use std::fs; + + fn source_tree(client: &str, models: &str) -> tempfile::TempDir { + let directory = tempfile::tempdir().unwrap(); + fs::write(directory.path().join("client.rs"), client).unwrap(); + fs::write(directory.path().join("models.rs"), models).unwrap(); + fs::write(directory.path().join("meta.rs"), "").unwrap(); + directory + } #[test] fn response_tree_reports_membership_and_non_option_fields() { @@ -221,7 +244,8 @@ mod tests { pub struct WidgetLeaf { pub value: Option } pub struct WidgetPostRequest { pub name: String, pub note: Option } "#; - let tree = response_tree(client, models).unwrap(); + let source = source_tree(client, models); + let tree = response_tree(source.path()).unwrap(); assert_eq!( tree.types, BTreeSet::from(["Widget".to_string(), "WidgetLeaf".to_string()]) @@ -305,8 +329,9 @@ mod tests { } "#; + let source = source_tree(client, models); assert_eq!( - integer_model_fields_typed_as_float(spec, client, models, &AnalyzerConfig::default()) + integer_model_fields_typed_as_float(spec, source.path(), &AnalyzerConfig::default()) .unwrap(), BTreeSet::from([ ("Widget".to_string(), "count".to_string()), diff --git a/crates/clickhouse-openapi-analyzer/src/main.rs b/crates/clickhouse-openapi-analyzer/src/main.rs index 65aebedc..7beeee83 100644 --- a/crates/clickhouse-openapi-analyzer/src/main.rs +++ b/crates/clickhouse-openapi-analyzer/src/main.rs @@ -13,11 +13,7 @@ struct Args { #[arg(long)] snapshot: PathBuf, #[arg(long)] - client: PathBuf, - #[arg(long)] - models: PathBuf, - #[arg(long)] - meta: PathBuf, + source_root: PathBuf, } fn main() { @@ -31,16 +27,11 @@ fn run() -> Result<(), Box> { let args = Args::parse(); let spec = fs::read_to_string(&args.spec)?; let snapshot = fs::read_to_string(&args.snapshot)?; - let client = fs::read_to_string(&args.client)?; - let models = fs::read_to_string(&args.models)?; - let meta = fs::read_to_string(&args.meta)?; let report = analyze( AnalysisInput { spec_json: &spec, snapshot_json: &snapshot, - client_rs: &client, - models_rs: &models, - meta_rs: &meta, + rust_source_root: &args.source_root, }, &clickhouse_cloud_config(), )?; diff --git a/crates/clickhouse-openapi-analyzer/src/rust_inventory.rs b/crates/clickhouse-openapi-analyzer/src/rust_inventory.rs index 02b95c82..d295e329 100644 --- a/crates/clickhouse-openapi-analyzer/src/rust_inventory.rs +++ b/crates/clickhouse-openapi-analyzer/src/rust_inventory.rs @@ -1,12 +1,359 @@ use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; use quote::ToTokens; use syn::ext::IdentExt; +use syn::parse::Parser; use syn::{ - Attribute, Expr, Fields, FnArg, GenericArgument, ImplItem, Item, Lit, Pat, PathArguments, Type, - Visibility, + Attribute, Expr, Fields, FnArg, GenericArgument, ImplItem, Item, Lit, Meta, Pat, PathArguments, + Type, Visibility, }; +#[derive(Debug, thiserror::Error)] +pub(crate) enum RustSourceError { + #[error("failed to read Rust source {path}: {source}")] + Read { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to parse Rust source {path}: {source}")] + Parse { + path: PathBuf, + #[source] + source: syn::Error, + }, + #[error( + "could not resolve module `{module}` declared in {declared_in}; tried {first} and {second}" + )] + ModuleNotFound { + module: String, + declared_in: PathBuf, + first: PathBuf, + second: PathBuf, + }, + #[error("invalid Rust source: {0}")] + Inventory(#[from] syn::Error), +} + +#[derive(Default)] +struct ModuleTree { + items: Vec, +} + +impl ModuleTree { + fn load(source_root: &Path, root_name: &str) -> Result { + let file_root = source_root.join(format!("{root_name}.rs")); + let directory_root = source_root.join(root_name).join("mod.rs"); + let root = if file_root.is_file() { + file_root + } else if directory_root.is_file() { + directory_root + } else { + return Err(RustSourceError::ModuleNotFound { + module: root_name.to_string(), + declared_in: source_root.join("lib.rs"), + first: file_root, + second: directory_root, + }); + }; + + let mut tree = Self::default(); + let mut loaded = BTreeSet::new(); + tree.load_file(&root, &mut loaded)?; + Ok(tree) + } + + #[cfg(test)] + fn parse(source: &str, name: &str) -> Result { + let file = syn::parse_file(source).map_err(|source| RustSourceError::Parse { + path: PathBuf::from(name), + source, + })?; + Ok(Self { items: file.items }) + } + + fn load_file( + &mut self, + path: &Path, + loaded: &mut BTreeSet, + ) -> Result<(), RustSourceError> { + if !loaded.insert(path.to_path_buf()) { + return Ok(()); + } + let source = fs::read_to_string(path).map_err(|source| RustSourceError::Read { + path: path.to_path_buf(), + source, + })?; + let file = syn::parse_file(&source).map_err(|source| RustSourceError::Parse { + path: path.to_path_buf(), + source, + })?; + let module_dir = child_module_dir(path); + self.collect_items(path, &module_dir, false, file.items, loaded) + } + + fn collect_items( + &mut self, + source_file: &Path, + module_dir: &Path, + inside_inline_module: bool, + items: Vec, + loaded: &mut BTreeSet, + ) -> Result<(), RustSourceError> { + for item in items { + let Item::Mod(item_mod) = item else { + self.items.push(item); + continue; + }; + + let module_name = item_mod.ident.unraw().to_string(); + let options = module_options(&item_mod.attrs)?; + if !options.active { + continue; + } + + if let Some((_, items)) = item_mod.content { + let child_dir = options.path.map_or_else( + || module_dir.join(&module_name), + |path| { + module_path_base(source_file, module_dir, inside_inline_module).join(path) + }, + ); + self.collect_items(source_file, &child_dir, true, items, loaded)?; + continue; + } + + if let Some(path) = options.path { + let path = + module_path_base(source_file, module_dir, inside_inline_module).join(path); + self.load_file(&path, loaded)?; + continue; + } + + let file_path = module_dir.join(format!("{module_name}.rs")); + let directory_path = module_dir.join(&module_name).join("mod.rs"); + if file_path.is_file() { + self.load_file(&file_path, loaded)?; + } else if directory_path.is_file() { + self.load_file(&directory_path, loaded)?; + } else { + return Err(RustSourceError::ModuleNotFound { + module: module_name, + declared_in: source_file.to_path_buf(), + first: file_path, + second: directory_path, + }); + } + } + Ok(()) + } +} + +fn module_path_base<'a>( + source_file: &'a Path, + module_dir: &'a Path, + inside_inline_module: bool, +) -> &'a Path { + if inside_inline_module { + module_dir + } else { + source_file.parent().unwrap_or(Path::new("")) + } +} + +fn child_module_dir(path: &Path) -> PathBuf { + if path.file_name().is_some_and(|name| name == "mod.rs") { + path.parent().unwrap_or(Path::new("")).to_path_buf() + } else { + path.with_extension("") + } +} + +struct ModuleOptions { + active: bool, + path: Option, +} + +fn module_options(attributes: &[Attribute]) -> syn::Result { + let mut options = ModuleOptions { + active: true, + path: None, + }; + for attribute in attributes { + apply_module_attribute(&attribute.meta, &mut options)?; + } + Ok(options) +} + +fn apply_module_attribute(meta: &Meta, options: &mut ModuleOptions) -> syn::Result<()> { + if meta.path().is_ident("cfg") { + let Meta::List(list) = meta else { + return Err(syn::Error::new_spanned(meta, "expected #[cfg(...)]")); + }; + let predicate = syn::parse2::(list.tokens.clone())?; + if evaluate_cfg(&predicate)? == CfgValue::False { + options.active = false; + } + } else if meta.path().is_ident("cfg_attr") { + let Meta::List(list) = meta else { + return Err(syn::Error::new_spanned(meta, "expected #[cfg_attr(...)]")); + }; + let nested = syn::punctuated::Punctuated::::parse_terminated + .parse2(list.tokens.clone())?; + let mut nested = nested.iter(); + let Some(predicate) = nested.next() else { + return Err(syn::Error::new_spanned( + meta, + "cfg_attr requires a predicate", + )); + }; + // Unknown target predicates stay conservative: do not apply an + // attribute that could hide portable API surface or select one + // platform-specific path over another. + if evaluate_cfg(predicate)? == CfgValue::True { + for attribute in nested { + apply_module_attribute(attribute, options)?; + } + } + } else if meta.path().is_ident("path") { + let Meta::NameValue(name_value) = meta else { + return Err(syn::Error::new_spanned(meta, "expected #[path = \"...\"]")); + }; + let Expr::Lit(literal) = &name_value.value else { + return Err(syn::Error::new_spanned( + meta, + "expected a string module path", + )); + }; + let Lit::Str(value) = &literal.lit else { + return Err(syn::Error::new_spanned( + meta, + "expected a string module path", + )); + }; + options.path = Some(PathBuf::from(value.value())); + } + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CfgValue { + True, + False, + Unknown, +} + +fn evaluate_cfg(predicate: &Meta) -> syn::Result { + match predicate { + Meta::Path(path) if path.is_ident("test") || path.is_ident("false") => Ok(CfgValue::False), + Meta::Path(path) if path.is_ident("true") => Ok(CfgValue::True), + Meta::Path(path) if path.is_ident("unix") => Ok(CfgValue::from(cfg!(unix))), + Meta::Path(path) if path.is_ident("windows") => Ok(CfgValue::from(cfg!(windows))), + Meta::Path(path) if path.is_ident("debug_assertions") => { + Ok(CfgValue::from(cfg!(debug_assertions))) + } + Meta::Path(path) if path.is_ident("proc_macro") => Ok(CfgValue::from(cfg!(proc_macro))), + // Unknown custom cfgs are retained so API inventory cannot silently + // disappear just because the analyzer was not passed a crate-specific + // `--cfg` flag. + Meta::Path(_) => Ok(CfgValue::Unknown), + Meta::NameValue(value) => evaluate_name_value_cfg(value), + Meta::List(list) if list.path.is_ident("not") => { + let nested = syn::parse2::(list.tokens.clone())?; + Ok(match evaluate_cfg(&nested)? { + CfgValue::True => CfgValue::False, + CfgValue::False => CfgValue::True, + CfgValue::Unknown => CfgValue::Unknown, + }) + } + Meta::List(list) if list.path.is_ident("all") || list.path.is_ident("any") => { + let nested = syn::punctuated::Punctuated::::parse_terminated + .parse2(list.tokens.clone())?; + let values = nested + .iter() + .map(evaluate_cfg) + .collect::>>()?; + if list.path.is_ident("all") { + if values.contains(&CfgValue::False) { + Ok(CfgValue::False) + } else if values.iter().all(|value| *value == CfgValue::True) { + Ok(CfgValue::True) + } else { + Ok(CfgValue::Unknown) + } + } else if values.contains(&CfgValue::True) { + Ok(CfgValue::True) + } else if values.iter().all(|value| *value == CfgValue::False) { + Ok(CfgValue::False) + } else { + Ok(CfgValue::Unknown) + } + } + Meta::List(_) => Ok(CfgValue::Unknown), + } +} + +impl From for CfgValue { + fn from(value: bool) -> Self { + if value { Self::True } else { Self::False } + } +} + +fn evaluate_name_value_cfg(value: &syn::MetaNameValue) -> syn::Result { + let Expr::Lit(literal) = &value.value else { + return Ok(CfgValue::Unknown); + }; + let Lit::Str(configured) = &literal.lit else { + return Ok(CfgValue::Unknown); + }; + let configured = configured.value(); + + // Analyzer inventory is intentionally all-features: feature-gated public + // API (including deprecated fields) must remain visible to drift checks. + if value.path.is_ident("feature") { + return Ok(CfgValue::True); + } + let actual = if value.path.is_ident("target_arch") { + Some(std::env::consts::ARCH) + } else if value.path.is_ident("target_os") { + Some(std::env::consts::OS) + } else if value.path.is_ident("target_family") { + Some(std::env::consts::FAMILY) + } else if value.path.is_ident("target_env") { + Some(env!("ANALYZER_TARGET_ENV")) + } else if value.path.is_ident("target_vendor") { + Some(env!("ANALYZER_TARGET_VENDOR")) + } else if value.path.is_ident("target_endian") { + Some(if cfg!(target_endian = "little") { + "little" + } else { + "big" + }) + } else if value.path.is_ident("target_pointer_width") { + Some(if usize::BITS == 64 { + "64" + } else if usize::BITS == 32 { + "32" + } else { + "16" + }) + } else if value.path.is_ident("panic") { + Some(if cfg!(panic = "unwind") { + "unwind" + } else { + "abort" + }) + } else { + None + }; + + Ok(actual + .map(|actual| CfgValue::from(actual == configured)) + .unwrap_or(CfgValue::Unknown)) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum TypeNode { Option(Box), @@ -84,7 +431,7 @@ impl TypeNode { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct FieldInfo { pub(crate) rust_name: String, pub(crate) rust_type: TypeNode, @@ -105,12 +452,12 @@ pub(crate) struct FieldInfo { pub(crate) skip_serializing_if: bool, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct StructInfo { pub(crate) fields: BTreeMap, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct EnumInfo { pub(crate) values: BTreeSet, pub(crate) is_value_enum: bool, @@ -120,7 +467,7 @@ pub(crate) struct EnumInfo { pub(crate) variant_type_names: BTreeSet, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct MethodInfo { pub(crate) arguments: BTreeMap, /// Every named type mentioned anywhere in the method's return type @@ -128,13 +475,13 @@ pub(crate) struct MethodInfo { pub(crate) return_type_names: BTreeSet, } -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct MetadataInventory { pub(crate) beta_operations: BTreeSet, pub(crate) deprecated_fields: BTreeSet<(String, String)>, } -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct RustInventory { pub(crate) client_methods: BTreeMap, pub(crate) model_types: BTreeSet, @@ -144,24 +491,40 @@ pub(crate) struct RustInventory { /// Named types mentioned anywhere in each alias's target type, for /// response-tree reachability (parallel to `aliases`). pub(crate) alias_type_names: BTreeMap>, + pub(crate) manual_default_impls: BTreeSet, pub(crate) metadata: MetadataInventory, } impl RustInventory { - pub(crate) fn parse(client: &str, models: &str, meta: &str) -> syn::Result { - let client_file = syn::parse_file(client)?; - let models_file = syn::parse_file(models)?; - let meta_file = syn::parse_file(meta)?; + pub(crate) fn load(source_root: &Path) -> Result { + let client = ModuleTree::load(source_root, "client")?; + let models = ModuleTree::load(source_root, "models")?; + let meta = ModuleTree::load(source_root, "meta")?; + Self::from_trees(&client, &models, &meta) + } + #[cfg(test)] + pub(crate) fn parse(client: &str, models: &str, meta: &str) -> Result { + let client = ModuleTree::parse(client, "client.rs")?; + let models = ModuleTree::parse(models, "models.rs")?; + let meta = ModuleTree::parse(meta, "meta.rs")?; + Self::from_trees(&client, &models, &meta) + } + + fn from_trees( + client: &ModuleTree, + models: &ModuleTree, + meta: &ModuleTree, + ) -> Result { let mut inventory = Self::default(); - inventory.collect_client(&client_file)?; - inventory.collect_models(&models_file)?; - inventory.collect_metadata(&meta_file); + inventory.collect_client(&client.items)?; + inventory.collect_models(&models.items)?; + inventory.collect_metadata(&meta.items); Ok(inventory) } - fn collect_client(&mut self, file: &syn::File) -> syn::Result<()> { - for item in &file.items { + fn collect_client(&mut self, items: &[Item]) -> syn::Result<()> { + for item in items { let Item::Impl(item_impl) = item else { continue; }; @@ -216,8 +579,8 @@ impl RustInventory { Ok(()) } - fn collect_models(&mut self, file: &syn::File) -> syn::Result<()> { - for item in &file.items { + fn collect_models(&mut self, items: &[Item]) -> syn::Result<()> { + for item in items { match item { Item::Struct(item_struct) if matches!(item_struct.vis, Visibility::Public(_)) => { let name = item_struct.ident.unraw().to_string(); @@ -301,13 +664,10 @@ impl RustInventory { } } // Second pass: impl blocks may lexically precede their enum declaration. - for item in &file.items { + for item in 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; }; @@ -319,6 +679,18 @@ impl RustInventory { let Some(name) = target else { continue; }; + if item_impl + .trait_ + .as_ref() + .and_then(|(_, path, _)| path.segments.last()) + .is_some_and(|segment| segment.ident == "Default") + { + self.manual_default_impls.insert(name); + continue; + } + if item_impl.trait_.is_some() { + continue; + } let Some(enum_info) = self.enums.get_mut(&name) else { continue; }; @@ -334,8 +706,8 @@ impl RustInventory { Ok(()) } - fn collect_metadata(&mut self, file: &syn::File) { - for item in &file.items { + fn collect_metadata(&mut self, items: &[Item]) { + for item in items { let Item::Const(item_const) = item else { continue; }; @@ -429,52 +801,25 @@ impl RustInventory { } } -/// Lists every public model struct field that carries a field-level -/// `#[serde(default)]` (bare or `default = "path"`), as -/// `StructName.rust_field_name`, sorted by struct name then wire field name. -/// -/// `cfg`-gated deprecated-marker fields are included, and a container-level -/// `#[serde(default)]` reports every field of its struct — the ban cannot be -/// dodged by moving the attribute up to the container. -pub(crate) fn model_fields_with_serde_default(models: &str) -> syn::Result> { - let inventory = RustInventory::parse("", models, "")?; - Ok(inventory - .structs - .iter() - .flat_map(|(struct_name, info)| { - info.fields - .values() - .filter(|field| field.serde_default) - .map(move |field| format!("{struct_name}.{}", field.rust_name)) - }) - .collect()) -} - -/// Lists every model type with a hand-written `impl Default for` block, -/// sorted by name. Derived `Default`s are deliberately excluded: only the -/// manual impls are the ones `discriminated_union!` enums use, and only those -/// carry the pick-a-variant decision the round-trip invariant guards. -pub(crate) fn model_types_with_manual_default_impl(models: &str) -> syn::Result> { - let file: syn::File = syn::parse_str(models)?; - let mut names: Vec = file - .items - .iter() - .filter_map(|item| { - let Item::Impl(item_impl) = item else { - return None; - }; - let (_, trait_path, _) = item_impl.trait_.as_ref()?; - if trait_path.segments.last()?.ident != "Default" { - return None; - } - let Type::Path(type_path) = item_impl.self_ty.as_ref() else { - return None; - }; - Some(type_path.path.segments.last()?.ident.unraw().to_string()) - }) - .collect(); - names.sort(); - Ok(names) +impl RustInventory { + /// Lists every public model struct field that carries a field-level + /// `#[serde(default)]` (bare or `default = "path"`), as + /// `StructName.rust_field_name`, sorted by struct name then wire field name. + /// + /// `cfg`-gated deprecated-marker fields are included, and a container-level + /// `#[serde(default)]` reports every field of its struct — the ban cannot be + /// dodged by moving the attribute up to the container. + pub(crate) fn model_fields_with_serde_default(&self) -> Vec { + self.structs + .iter() + .flat_map(|(struct_name, info)| { + info.fields + .values() + .filter(|field| field.serde_default) + .map(move |field| format!("{struct_name}.{}", field.rust_name)) + }) + .collect() + } } /// Collects every named type appearing anywhere in `ty`, including inside @@ -647,6 +992,120 @@ fn string_pair_array(expression: &Expr) -> BTreeSet<(String, String)> { mod tests { use super::*; + fn module_tree_fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("module_tree") + .join(name) + } + + #[test] + fn private_nested_module_tree_matches_flat_inventory() { + let flat = RustInventory::load(&module_tree_fixture("flat")).unwrap(); + let nested = RustInventory::load(&module_tree_fixture("nested")).unwrap(); + + assert_eq!(nested, flat); + assert!(nested.client_methods.contains_key("list_widgets")); + assert_eq!( + nested.model_types, + BTreeSet::from([ + "Widget".to_string(), + "WidgetAlias".to_string(), + "WidgetLeaf".to_string(), + "WidgetState".to_string(), + ]) + ); + assert_eq!( + nested.enums["WidgetState"].values_const, + Some(BTreeSet::from(["ready".to_string()])) + ); + assert_eq!( + nested.manual_default_impls, + BTreeSet::from(["WidgetState".to_string()]) + ); + assert_eq!( + nested.model_fields_with_serde_default(), + vec!["Widget.item_count".to_string()] + ); + assert_eq!( + nested.terminal_type(&nested.structs["Widget"].fields["itemCount"].rust_type), + Some("f64".to_string()) + ); + } + + #[test] + fn path_attributes_follow_rust_inline_module_context() { + let inventory = RustInventory::load(&module_tree_fixture("path_context")).unwrap(); + + assert_eq!( + inventory.model_types, + BTreeSet::from([ + "DirectPathModel".to_string(), + "InlinePathModel".to_string(), + "RelocatedInlinePathModel".to_string(), + ]) + ); + } + + #[test] + fn inactive_cfg_modules_are_not_loaded_or_inventoried() { + let inventory = RustInventory::load(&module_tree_fixture("cfg_modules")).unwrap(); + + assert_eq!( + inventory.model_types, + BTreeSet::from([ + "CustomCfgModel".to_string(), + "DeprecatedModel".to_string(), + "FeatureModel".to_string(), + "PlatformModel".to_string(), + "ProductionModel".to_string(), + ]) + ); + assert_eq!( + inventory.client_methods.keys().collect::>(), + vec!["production_operation"] + ); + assert_eq!( + inventory.metadata.beta_operations, + BTreeSet::from(["production_operation".to_string()]) + ); + } + + #[test] + fn cfg_evaluation_matches_the_analyzer_target() { + fn value(source: &str) -> CfgValue { + evaluate_cfg(&syn::parse_str(source).unwrap()).unwrap() + } + + fn assert_target_value(name: &str, actual: &str) { + assert_eq!(value(&format!(r#"{name} = "{actual}""#)), CfgValue::True); + assert_eq!( + value(&format!(r#"{name} = "definitely-not-{actual}""#)), + CfgValue::False + ); + } + + assert_eq!(value("unix"), CfgValue::from(cfg!(unix))); + assert_eq!(value("windows"), CfgValue::from(cfg!(windows))); + assert_target_value("target_arch", std::env::consts::ARCH); + assert_target_value("target_os", std::env::consts::OS); + assert_target_value("target_family", std::env::consts::FAMILY); + assert_target_value("target_env", env!("ANALYZER_TARGET_ENV")); + assert_target_value("target_vendor", env!("ANALYZER_TARGET_VENDOR")); + assert_target_value( + "target_endian", + if cfg!(target_endian = "little") { + "little" + } else { + "big" + }, + ); + assert_target_value("target_pointer_width", &usize::BITS.to_string()); + assert_eq!(value(r#"feature = "any-feature""#), CfgValue::True); + assert_eq!(value("clickhouse_custom"), CfgValue::Unknown); + } + #[test] fn inventories_structural_rust_and_serde_details() { let client = r#" @@ -804,7 +1263,9 @@ mod tests { "#; assert_eq!( - model_fields_with_serde_default(models).unwrap(), + RustInventory::parse("", models, "") + .unwrap() + .model_fields_with_serde_default(), vec![ "Widget.created_at".to_string(), "Widget.legacy_name".to_string(), @@ -832,8 +1293,10 @@ mod tests { "#; assert_eq!( - model_types_with_manual_default_impl(models).unwrap(), - vec!["Union".to_string(), "Widget".to_string()] + RustInventory::parse("", models, "") + .unwrap() + .manual_default_impls, + BTreeSet::from(["Union".to_string(), "Widget".to_string()]) ); } @@ -848,7 +1311,9 @@ mod tests { "#; assert_eq!( - model_fields_with_serde_default(models).unwrap(), + RustInventory::parse("", models, "") + .unwrap() + .model_fields_with_serde_default(), vec!["Widget.description".to_string(), "Widget.name".to_string()] ); } diff --git a/crates/clickhouse-openapi-analyzer/tests/entrypoint_test.rs b/crates/clickhouse-openapi-analyzer/tests/entrypoint_test.rs index 5b83ef64..ec3224ee 100644 --- a/crates/clickhouse-openapi-analyzer/tests/entrypoint_test.rs +++ b/crates/clickhouse-openapi-analyzer/tests/entrypoint_test.rs @@ -1,49 +1,35 @@ use std::collections::BTreeSet; -use std::fs; +use std::path::Path; use std::process::Command; use clickhouse_openapi_analyzer::config::clickhouse_cloud_config; -use clickhouse_openapi_analyzer::report::DriftReport; +use clickhouse_openapi_analyzer::report::{DriftReport, REPORT_SCHEMA_VERSION}; use clickhouse_openapi_analyzer::{AnalysisInput, analyze}; const SPEC: &str = include_str!("../../clickhouse-cloud-api/clickhouse_cloud_openapi.json"); -const CLIENT: &str = include_str!("../../clickhouse-cloud-api/src/client.rs"); -const MODELS: &str = include_str!("../../clickhouse-cloud-api/src/models.rs"); -const META: &str = include_str!("../../clickhouse-cloud-api/src/meta.rs"); +const API_ROOT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../clickhouse-cloud-api"); #[test] fn executable_and_library_return_the_same_vendored_report() { let config = clickhouse_cloud_config(); + let api_root = Path::new(API_ROOT); + let spec = api_root.join("clickhouse_cloud_openapi.json"); + let source_root = api_root.join("src"); + let expected = analyze( AnalysisInput { spec_json: SPEC, snapshot_json: SPEC, - client_rs: CLIENT, - models_rs: MODELS, - meta_rs: META, + rust_source_root: &source_root, }, &config, ) .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let spec = directory.path().join("spec.json"); - let snapshot = directory.path().join("snapshot.json"); - let client = directory.path().join("client.rs"); - let models = directory.path().join("models.rs"); - let meta = directory.path().join("meta.rs"); - fs::write(&spec, SPEC).unwrap(); - fs::write(&snapshot, SPEC).unwrap(); - fs::write(&client, CLIENT).unwrap(); - fs::write(&models, MODELS).unwrap(); - fs::write(&meta, META).unwrap(); - let output = Command::new(env!("CARGO_BIN_EXE_openapi-drift-analyzer")) .args(["--spec", spec.to_str().unwrap()]) - .args(["--snapshot", snapshot.to_str().unwrap()]) - .args(["--client", client.to_str().unwrap()]) - .args(["--models", models.to_str().unwrap()]) - .args(["--meta", meta.to_str().unwrap()]) + .args(["--snapshot", spec.to_str().unwrap()]) + .args(["--source-root", source_root.to_str().unwrap()]) .output() .unwrap(); assert!( @@ -53,7 +39,12 @@ fn executable_and_library_return_the_same_vendored_report() { ); let actual: DriftReport = serde_json::from_slice(&output.stdout).unwrap(); + // Exact executable/library equality checks the source-tree input boundary. + // The vendored spec is the independent inventory oracle: a dropped method, + // model, field, or enum produces actionable drift, while the response-tree + // policy tests separately retain their model-count vacuity guard. assert_eq!(actual, expected); + assert_eq!(actual.schema_version, REPORT_SCHEMA_VERSION); assert!(!actual.has_drift(), "{}", actual.render_text()); let reported_pointers = actual .unsupported_enum_constraints diff --git a/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/cfg_modules/client.rs b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/cfg_modules/client.rs new file mode 100644 index 00000000..4ee060a9 --- /dev/null +++ b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/cfg_modules/client.rs @@ -0,0 +1,14 @@ +pub struct Client; + +#[cfg(test)] +mod missing_tests; + +#[cfg(any())] +mod missing_never; + +#[cfg(not(test))] +mod operations { + impl Client { + pub async fn production_operation(&self) {} + } +} diff --git a/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/cfg_modules/meta.rs b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/cfg_modules/meta.rs new file mode 100644 index 00000000..8c29b2d7 --- /dev/null +++ b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/cfg_modules/meta.rs @@ -0,0 +1,5 @@ +#[cfg(test)] +mod missing_tests; + +pub const BETA_OPERATIONS: &[&str] = &["production_operation"]; +pub const DEPRECATED_FIELDS: &[(&str, &str)] = &[]; diff --git a/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/cfg_modules/models.rs b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/cfg_modules/models.rs new file mode 100644 index 00000000..db080585 --- /dev/null +++ b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/cfg_modules/models.rs @@ -0,0 +1,45 @@ +#[cfg(test)] +mod test_only { + pub struct TestOnlyModel; +} + +#[cfg(any())] +mod missing_never; + +#[cfg_attr(not(test), cfg(any()))] +mod missing_via_cfg_attr; + +#[cfg(all(unix, windows))] +mod missing_inactive_platform; + +#[cfg_attr(test, path = "missing_test.rs")] +#[cfg_attr(not(test), path = "production.rs")] +mod selected; +pub use selected::*; + +#[cfg(feature = "deprecated-fields")] +mod deprecated { + pub struct DeprecatedModel; +} +pub use deprecated::*; + +#[cfg(feature = "not-enabled")] +mod feature_gated { + pub struct FeatureModel; +} +pub use feature_gated::*; + +#[cfg(clickhouse_custom)] +mod custom_cfg { + pub struct CustomCfgModel; +} +pub use custom_cfg::*; + +#[cfg_attr(target_family = "unix", path = "platform_unix.rs")] +#[cfg_attr(target_family = "windows", path = "platform_windows.rs")] +#[cfg_attr( + not(any(target_family = "unix", target_family = "windows")), + path = "platform_other.rs" +)] +mod platform; +pub use platform::*; diff --git a/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/cfg_modules/platform_other.rs b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/cfg_modules/platform_other.rs new file mode 100644 index 00000000..20edbea3 --- /dev/null +++ b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/cfg_modules/platform_other.rs @@ -0,0 +1 @@ +pub struct PlatformModel; diff --git a/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/cfg_modules/platform_unix.rs b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/cfg_modules/platform_unix.rs new file mode 100644 index 00000000..20edbea3 --- /dev/null +++ b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/cfg_modules/platform_unix.rs @@ -0,0 +1 @@ +pub struct PlatformModel; diff --git a/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/cfg_modules/platform_windows.rs b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/cfg_modules/platform_windows.rs new file mode 100644 index 00000000..20edbea3 --- /dev/null +++ b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/cfg_modules/platform_windows.rs @@ -0,0 +1 @@ +pub struct PlatformModel; diff --git a/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/cfg_modules/production.rs b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/cfg_modules/production.rs new file mode 100644 index 00000000..b028ec42 --- /dev/null +++ b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/cfg_modules/production.rs @@ -0,0 +1 @@ +pub struct ProductionModel; diff --git a/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/flat/client.rs b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/flat/client.rs new file mode 100644 index 00000000..5997cef8 --- /dev/null +++ b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/flat/client.rs @@ -0,0 +1,10 @@ +pub struct Client; + +impl Client { + pub async fn list_widgets( + &self, + state: Option<&WidgetState>, + ) -> Result { + unimplemented!() + } +} diff --git a/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/flat/meta.rs b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/flat/meta.rs new file mode 100644 index 00000000..e3528431 --- /dev/null +++ b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/flat/meta.rs @@ -0,0 +1,2 @@ +pub const BETA_OPERATIONS: &[&str] = &["list_widgets"]; +pub const DEPRECATED_FIELDS: &[(&str, &str)] = &[("Widget", "oldName")]; diff --git a/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/flat/models.rs b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/flat/models.rs new file mode 100644 index 00000000..e7693b54 --- /dev/null +++ b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/flat/models.rs @@ -0,0 +1,29 @@ +pub struct Widget { + #[serde(rename = "itemCount", default)] + pub item_count: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub leaf: Option, +} + +pub struct WidgetLeaf { + pub name: String, +} + +pub enum WidgetState { + #[serde(rename = "ready")] + Ready, + #[serde(untagged)] + Unknown(String), +} + +impl WidgetState { + pub const VALUES: &'static [&'static str] = &["ready"]; +} + +impl Default for WidgetState { + fn default() -> Self { + Self::Ready + } +} + +pub type WidgetAlias = Vec; diff --git a/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/nested/client.rs b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/nested/client.rs new file mode 100644 index 00000000..0e2436a0 --- /dev/null +++ b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/nested/client.rs @@ -0,0 +1,9 @@ +pub struct Client; + +mod operations { + mod widgets; + + pub use widgets::*; +} + +pub use operations::*; diff --git a/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/nested/client/operations/widgets.rs b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/nested/client/operations/widgets.rs new file mode 100644 index 00000000..764e892f --- /dev/null +++ b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/nested/client/operations/widgets.rs @@ -0,0 +1,8 @@ +impl Client { + pub async fn list_widgets( + &self, + state: Option<&WidgetState>, + ) -> Result { + unimplemented!() + } +} diff --git a/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/nested/meta.rs b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/nested/meta.rs new file mode 100644 index 00000000..0eedb3cc --- /dev/null +++ b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/nested/meta.rs @@ -0,0 +1,6 @@ +mod generated { + pub const BETA_OPERATIONS: &[&str] = &["list_widgets"]; + pub const DEPRECATED_FIELDS: &[(&str, &str)] = &[("Widget", "oldName")]; +} + +pub use generated::*; diff --git a/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/nested/models.rs b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/nested/models.rs new file mode 100644 index 00000000..09f28dd8 --- /dev/null +++ b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/nested/models.rs @@ -0,0 +1,3 @@ +mod domain; + +pub use domain::*; diff --git a/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/nested/models/domain.rs b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/nested/models/domain.rs new file mode 100644 index 00000000..f29b360f --- /dev/null +++ b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/nested/models/domain.rs @@ -0,0 +1,33 @@ +pub struct Widget { + #[serde(rename = "itemCount", default)] + pub item_count: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub leaf: Option, +} + +mod nested { + pub struct WidgetLeaf { + pub name: String, + } +} + +pub use nested::*; + +pub enum WidgetState { + #[serde(rename = "ready")] + Ready, + #[serde(untagged)] + Unknown(String), +} + +impl WidgetState { + pub const VALUES: &'static [&'static str] = &["ready"]; +} + +impl Default for WidgetState { + fn default() -> Self { + Self::Ready + } +} + +pub type WidgetAlias = Vec; diff --git a/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/path_context/client.rs b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/path_context/client.rs new file mode 100644 index 00000000..bf28c619 --- /dev/null +++ b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/path_context/client.rs @@ -0,0 +1 @@ +pub struct Client; diff --git a/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/path_context/lib.rs b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/path_context/lib.rs new file mode 100644 index 00000000..ddd5c102 --- /dev/null +++ b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/path_context/lib.rs @@ -0,0 +1 @@ +mod models; diff --git a/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/path_context/meta.rs b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/path_context/meta.rs new file mode 100644 index 00000000..ccee5f92 --- /dev/null +++ b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/path_context/meta.rs @@ -0,0 +1,2 @@ +pub const BETA_OPERATIONS: &[&str] = &[]; +pub const DEPRECATED_FIELDS: &[(&str, &str)] = &[]; diff --git a/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/path_context/model_files/direct.rs b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/path_context/model_files/direct.rs new file mode 100644 index 00000000..6a9cbe4c --- /dev/null +++ b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/path_context/model_files/direct.rs @@ -0,0 +1 @@ +pub struct DirectPathModel; diff --git a/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/path_context/models.rs b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/path_context/models.rs new file mode 100644 index 00000000..d92c5b65 --- /dev/null +++ b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/path_context/models.rs @@ -0,0 +1,20 @@ +#[path = "model_files/direct.rs"] +mod direct; +pub use direct::*; + +mod inline { + #[path = "renamed.rs"] + mod nested; + + pub use nested::*; +} +pub use inline::*; + +#[path = "relocated"] +mod relocated { + #[path = "leaf.rs"] + mod leaf; + + pub use leaf::*; +} +pub use relocated::*; diff --git a/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/path_context/models/inline/renamed.rs b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/path_context/models/inline/renamed.rs new file mode 100644 index 00000000..fb2f3619 --- /dev/null +++ b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/path_context/models/inline/renamed.rs @@ -0,0 +1 @@ +pub struct InlinePathModel; diff --git a/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/path_context/relocated/leaf.rs b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/path_context/relocated/leaf.rs new file mode 100644 index 00000000..140ac178 --- /dev/null +++ b/crates/clickhouse-openapi-analyzer/tests/fixtures/module_tree/path_context/relocated/leaf.rs @@ -0,0 +1 @@ +pub struct RelocatedInlinePathModel; diff --git a/scripts/check-openapi-drift.py b/scripts/check-openapi-drift.py index 02c2b2dd..caa6276d 100755 --- a/scripts/check-openapi-drift.py +++ b/scripts/check-openapi-drift.py @@ -19,9 +19,7 @@ REPO_ROOT = Path(__file__).resolve().parent.parent API_ROOT = REPO_ROOT / "crates" / "clickhouse-cloud-api" -CLIENT_RS = API_ROOT / "src" / "client.rs" -MODELS_RS = API_ROOT / "src" / "models.rs" -META_RS = API_ROOT / "src" / "meta.rs" +RUST_SOURCE_ROOT = API_ROOT / "src" SNAPSHOT_JSON = API_ROOT / "clickhouse_cloud_openapi.json" LIVE_SPEC_URL = os.environ.get( "CLICKHOUSE_OPENAPI_SPEC_URL", "https://api.clickhouse.cloud/v1" @@ -77,12 +75,8 @@ def run_analyzer(spec: dict) -> dict: str(spec_path), "--snapshot", str(SNAPSHOT_JSON), - "--client", - str(CLIENT_RS), - "--models", - str(MODELS_RS), - "--meta", - str(META_RS), + "--source-root", + str(RUST_SOURCE_ROOT), ] result = subprocess.run( command, diff --git a/scripts/tests/test_check_openapi_drift.py b/scripts/tests/test_check_openapi_drift.py index a051e632..f0db9ac1 100644 --- a/scripts/tests/test_check_openapi_drift.py +++ b/scripts/tests/test_check_openapi_drift.py @@ -241,6 +241,24 @@ def fake_run(cmd, *args, **kwargs): self.assertTrue(comment_inputs) self.assertFalse(any("incomplete" in i for i in comment_inputs)) + @mock.patch.object(drift.subprocess, "run") + def test_analyzer_receives_the_rust_source_tree(self, run): + run.return_value = SimpleNamespace( + returncode=0, + stdout=json.dumps({"schema_version": 2, "findings": []}), + stderr="", + ) + + drift.run_analyzer({"paths": {}, "components": {"schemas": {}}}) + + command = run.call_args.args[0] + self.assertIn("--source-root", command) + source_root_index = command.index("--source-root") + 1 + self.assertEqual(command[source_root_index], str(drift.RUST_SOURCE_ROOT)) + self.assertNotIn("--client", command) + self.assertNotIn("--models", command) + self.assertNotIn("--meta", command) + @mock.patch.object(drift.subprocess, "run") def test_analyzer_subprocess_failure_is_fatal(self, run): run.return_value = SimpleNamespace(returncode=1, stdout="", stderr="bad source")