diff --git a/Cargo.lock b/Cargo.lock index c5fd3ca..2751bdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -841,7 +841,7 @@ dependencies = [ [[package]] name = "endpoint-gen" -version = "1.9.0" +version = "1.10.0" dependencies = [ "clap", "convert_case 0.10.0", @@ -873,9 +873,9 @@ dependencies = [ [[package]] name = "endpoint-libs" -version = "1.9.0" +version = "2.0.0-alpha.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a445d34b3a27b3096fd12c0db3d81810ddad22aa4af09a81178110c8ebab201" +checksum = "c01f4bd8850b41dd1cb2df5a5a32f5bc85c295903b55d62941424e0009d2e7c0" dependencies = [ "alloy-primitives", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 6202025..d51abd3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "endpoint-gen" -version = "1.9.0" +version = "1.10.0" edition = "2024" repository = "https://github.com/pathscale/EndpointGen/" description = "Generate Rust code for websocket API endpoints" @@ -10,7 +10,7 @@ license = "MIT" [dependencies] # Internal dependencies -endpoint-libs = "1.9.0" +endpoint-libs = "2.0.0-alpha.1" endpoint-gen-macros = { path = "./endpoint-gen-macros", version = "1.3.4" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/src/definitions.rs b/src/definitions.rs index 805afd1..d10c739 100644 --- a/src/definitions.rs +++ b/src/definitions.rs @@ -137,19 +137,29 @@ impl ToRust for EnumElement { let mut fields = fields .iter() .map(|x| { - format!( - r#" + let variant_name = if x.name.chars().last().unwrap().is_lowercase() { + x.name.to_case(Case::Pascal) + } else { + x.name.clone() + }; + // A blank description must omit the `///` line entirely: an + // empty doc comment trips clippy::empty_docs downstream. + if x.description.trim().is_empty() { + format!( + r#" + {} = {} +"#, + variant_name, x.value + ) + } else { + format!( + r#" /// {} {} = {} "#, - x.description, - if x.name.chars().last().unwrap().is_lowercase() { - x.name.to_case(Case::Pascal) - } else { - x.name.clone() - }, - x.value - ) + x.description, variant_name, x.value + ) + } }) .sorted_by(|a, b| { // Sort by the endpoint code @@ -417,17 +427,11 @@ pub struct EndpointSchemaElement { impl From for EndpointSchema { fn from(val: EndpointSchemaElement) -> Self { - EndpointSchema { - name: val.schema.name, - code: val.schema.code, - parameters: val.schema.parameters, - returns: val.schema.returns, - stream_response: val.schema.stream_response, - description: val.schema.description, - json_schema: val.schema.json_schema, - roles: val.schema.roles, - errors: val.schema.errors, - } + // Was a field-by-field copy, which `#[non_exhaustive]` (endpoint-libs 2.0) + // now forbids from outside that crate — and which was always a no-op, since + // the source field *is* an EndpointSchema. Moving it also means new fields + // (e.g. `meta`) are carried through automatically instead of being dropped. + val.schema } } @@ -457,3 +461,32 @@ impl GenElement for EndpointSchemaListDefinition { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use endpoint_libs::model::EnumVariant; + + #[test] + fn enum_element_decl_omits_doc_comment_for_blank_descriptions() { + let element = EnumElement { + config: RustGenConfig::default(), + inner: Type::enum_( + "sample", + vec![ + EnumVariant::new_with_description("Documented", "Has docs.", 0), + EnumVariant::new("Bare", 1), + EnumVariant::new_with_description("Blank", " ", 2), + ], + ), + }; + let decl = element.to_rust_decl(false, false); + assert!(decl.contains("/// Has docs.")); + assert!( + !decl.lines().any(|l| l.trim() == "///"), + "empty doc comment emitted (clippy::empty_docs):\n{decl}" + ); + assert!(decl.contains("Bare = 1")); + assert!(decl.contains("Blank = 2")); + } +} diff --git a/src/main.rs b/src/main.rs index 473f910..6b925c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,6 +33,12 @@ struct Cli { /// Output directory for the generated files #[arg(short, long)] output_dir: Option, + + /// Allow endpoint and enum-variant descriptions to be missing or blank + /// (legacy behavior). By default, generation fails on empty descriptions + /// since they produce useless MCP tool metadata and docs. + #[arg(long)] + allow_empty_descriptions: bool, } fn main() -> Result<()> { @@ -61,7 +67,7 @@ fn main() -> Result<()> { let output_dir = generation_root.join("generated"); - let input_objects = build_object_lists(config_dir)?; + let input_objects = build_object_lists(config_dir, args.allow_empty_descriptions)?; let data = Data { project_root: generation_root, @@ -169,7 +175,84 @@ fn process_file(file_path: &Path) -> eyre::Result> { } } -fn process_input_files(dir: PathBuf) -> eyre::Result> { +/// Returns one violation string per missing/blank description in the +/// definition. Endpoint descriptions become MCP tool descriptions and doc +/// text; enum variant descriptions are emitted into the generated JSON +/// schemas — both are validated. +fn description_violations(definition: &Definition, path: &Path) -> Vec { + fn blank(s: &str) -> bool { + s.trim().is_empty() + } + + fn check_enum(inner: &Type, path: &Path, violations: &mut Vec) { + if let Type::Enum { name, variants } = inner { + for variant in variants { + if blank(&variant.description) { + violations.push(format!( + "{}: enum '{}' variant '{}': missing or empty description", + path.display(), + name, + variant.name + )); + } + } + } + } + + let mut violations = vec![]; + match definition { + Definition::EndpointSchema(def) => { + if blank(&def.schema.schema.description) { + violations.push(format!( + "{}: service '{}' endpoint '{}': missing or empty description", + path.display(), + def.service_name, + def.schema.schema.name + )); + } + } + Definition::EndpointSchemaList(def) => { + for endpoint in &def.endpoints { + if blank(&endpoint.schema.description) { + violations.push(format!( + "{}: service '{}' endpoint '{}': missing or empty description", + path.display(), + def.service_name, + endpoint.schema.name + )); + } + } + } + Definition::Enum(element) => check_enum(&element.inner, path, &mut violations), + Definition::EnumList(list) => { + for element in &list.enum_elements { + check_enum(&element.inner, path, &mut violations); + } + } + Definition::ErrorCodeList(list) => { + // Error-code descriptions are not cosmetic either: they become the doc + // comments on the generated `EnumErrorCode` variants and the third + // column of docs/error_codes/error_codes.md. A blank one produces an + // error a caller cannot interpret. + for code in &list.codes { + if blank(&code.description) { + violations.push(format!( + "{}: error code '{}' ({}): missing or empty description", + path.display(), + code.name, + code.code + )); + } + } + } + // Struct fields cannot carry RON descriptions — Field.description is + // #[serde(skip)] upstream — so struct definitions can never violate. + Definition::Struct(_) | Definition::StructList(_) => {} + } + violations +} + +fn process_input_files(dir: PathBuf, allow_empty_descriptions: bool) -> eyre::Result> { let root = dir.as_path(); // Walk through the directory and all subdirectories @@ -185,10 +268,14 @@ fn process_input_files(dir: PathBuf) -> eyre::Result> { let mut rust_configs: Vec = vec![]; let mut valid_config_files_counter = 0u32; let mut config_errors = vec![]; + let mut description_errors = vec![]; for path in paths { match process_file(path.as_path()) { Ok(rust_config) => { if let Some(config) = rust_config { + if !allow_empty_descriptions { + description_errors.extend(description_violations(&config, path.as_path())); + } rust_configs.push(config); valid_config_files_counter += 1; } @@ -205,6 +292,17 @@ fn process_input_files(dir: PathBuf) -> eyre::Result> { bail!("Error processing RON config files:\n{}", config_errors.join("\n")); } + if !description_errors.is_empty() { + bail!( + "Empty-description validation failed for {} item(s). Every endpoint, enum variant \ + and error code needs a description (these become MCP tool metadata, generated doc \ + comments and the error-code reference). Pass --allow-empty-descriptions to \ + bypass:\n{}", + description_errors.len(), + description_errors.join("\n") + ); + } + // If we haven't found any files, it's better to just return here immediately if valid_config_files_counter == 0 { bail!("No valid RON config files found in given path, aborting generation process"); @@ -220,8 +318,8 @@ struct InputObjects { error_codes: Vec, } -fn build_object_lists(dir: PathBuf) -> eyre::Result { - let rust_configs = process_input_files(dir)?; +fn build_object_lists(dir: PathBuf, allow_empty_descriptions: bool) -> eyre::Result { + let rust_configs = process_input_files(dir, allow_empty_descriptions)?; let mut service_schema_map: HashMap<(String, u16), Vec> = HashMap::new(); @@ -390,15 +488,17 @@ mod tests { vec![Field::new("user_name", Type::String)], vec![Field::new("access_token", Type::String)], ) - .with_errors(vec![EndpointErrorSchema { - name: "PasswordTooShort".to_string(), - code: EndpointErrorCodeRef::new("BadRequest"), - message: "Password too short".to_string(), - fields: vec![ + .with_errors(vec![ + EndpointErrorSchema::new( + "PasswordTooShort", + EndpointErrorCodeRef::new("BadRequest"), + ) + .with_message("Password too short") + .with_fields(vec![ Field::new("min_length", Type::Int32), Field::new("actual_length", Type::Int32), - ], - }]), + ]), + ]), }], )], enums: vec![], @@ -414,4 +514,97 @@ mod tests { assert_eq!(endpoint.errors[0].fields[0].name, "minLength"); assert_eq!(endpoint.errors[0].fields[1].name, "actualLength"); } + + use endpoint_gen::definitions::EndpointSchemaListDefinition; + use endpoint_libs::model::EnumVariant; + + fn endpoint_list(descriptions: &[&str]) -> Definition { + Definition::EndpointSchemaList(EndpointSchemaListDefinition { + service_name: "userApi".to_string(), + service_id: 6, + config: RustGenConfig::default(), + endpoints: descriptions + .iter() + .enumerate() + .map(|(i, desc)| EndpointSchemaElement { + frontend_facing: true, + config: RustGenConfig::default(), + schema: EndpointSchema::new(format!("Endpoint{i}"), 60000 + i as u32, vec![], vec![]) + .with_description(*desc), + }) + .collect(), + }) + } + + fn enum_definition(variant_descriptions: &[&str]) -> Definition { + Definition::Enum(EnumElement { + config: RustGenConfig::default(), + inner: Type::Enum { + name: "UserRole".to_string(), + variants: variant_descriptions + .iter() + .enumerate() + .map(|(i, desc)| EnumVariant::new_with_description(format!("Variant{i}"), desc.to_string(), i as i64)) + .collect(), + }, + }) + } + + #[test] + fn description_violations_flags_empty_and_whitespace_endpoints() { + let path = Path::new("config/schema_lists/060_user/061_user_api.ron"); + let violations = description_violations(&endpoint_list(&["Fetches a profile.", "", " \t"]), path); + assert_eq!(violations.len(), 2); + assert!(violations[0].contains("service 'userApi'")); + assert!(violations[0].contains("endpoint 'Endpoint1'")); + assert!(violations[0].contains("061_user_api.ron")); + assert!(violations[1].contains("endpoint 'Endpoint2'")); + } + + #[test] + fn description_violations_passes_documented_endpoints() { + let path = Path::new("config/a.ron"); + let violations = description_violations(&endpoint_list(&["Documented.", "Also documented."]), path); + assert!(violations.is_empty()); + } + + #[test] + fn description_violations_flags_blank_enum_variants() { + let path = Path::new("config/enums.ron"); + let violations = description_violations(&enum_definition(&["Platform admin", "", " "]), path); + assert_eq!(violations.len(), 2); + assert!(violations[0].contains("enum 'UserRole'")); + assert!(violations[0].contains("variant 'Variant1'")); + assert!(violations[1].contains("variant 'Variant2'")); + } + + #[test] + fn description_violations_ignores_structs() { + // Struct fields cannot carry RON descriptions (Field.description is + // serde-skipped upstream), so StructList definitions never violate. + let path = Path::new("config/structs.ron"); + let def = Definition::StructList(endpoint_gen::definitions::StructListDefinition { + config: RustGenConfig::default(), + struct_elements: vec![], + }); + assert!(description_violations(&def, path).is_empty()); + } + + #[test] + fn description_violations_flags_blank_error_codes() { + use endpoint_gen::definitions::{ErrorCodeListDefinition, ErrorCodeSchema}; + let path = Path::new("config/error_codes.ron"); + let def = Definition::ErrorCodeList(ErrorCodeListDefinition { + codes: vec![ + ErrorCodeSchema::new("BadRequest", 400, "The request was malformed."), + ErrorCodeSchema::new("Teapot", 418, ""), + ErrorCodeSchema::new("Blank", 419, " \t "), + ], + }); + let violations = description_violations(&def, path); + assert_eq!(violations.len(), 2, "{violations:?}"); + assert!(violations[0].contains("error code 'Teapot' (418)"), "{violations:?}"); + assert!(violations[1].contains("error code 'Blank' (419)"), "{violations:?}"); + assert!(violations[0].contains("error_codes.ron")); + } } diff --git a/src/rust.rs b/src/rust.rs index f44f451..a6263a3 100644 --- a/src/rust.rs +++ b/src/rust.rs @@ -55,6 +55,14 @@ impl ToRust for Type { Type::BlockchainTransactionHash if serde_with => "H256".to_owned(), Type::BlockchainAddress => "BlockchainAddress".to_owned(), Type::BlockchainTransactionHash => "BlockchainTransactionHash".to_owned(), + // `Type` is #[non_exhaustive] as of endpoint-libs 2.0, so a newer libs + // release can add variants without breaking this build. Panicking is the + // right behaviour: emitting Rust for a type we do not understand would + // produce silently wrong generated code. + other => panic!( + "endpoint-gen does not know how to emit Rust for {other:?}; \ + upgrade endpoint-gen to match your endpoint-libs version" + ), } } @@ -101,19 +109,29 @@ impl ToRust for Type { let mut fields = fields .iter() .map(|x| { - format!( - r#" + let variant_name = if x.name.chars().last().unwrap().is_lowercase() { + x.name.to_case(Case::Pascal) + } else { + x.name.clone() + }; + // A blank description must omit the `///` line entirely: an + // empty doc comment trips clippy::empty_docs downstream. + if x.description.trim().is_empty() { + format!( + r#" + {} = {} +"#, + variant_name, x.value + ) + } else { + format!( + r#" /// {} {} = {} "#, - x.description, - if x.name.chars().last().unwrap().is_lowercase() { - x.name.to_case(Case::Pascal) - } else { - x.name.clone() - }, - x.value - ) + x.description, variant_name, x.value + ) + } }) .sorted_by(|a, b| { // Sort by the endpoint code @@ -564,6 +582,26 @@ mod tests { use crate::definitions::{EndpointSchemaElement, RustGenConfig}; use endpoint_libs::model::{EndpointSchema, Field}; + #[test] + fn enum_decl_omits_doc_comment_for_blank_descriptions() { + let e = Type::enum_( + "sample", + vec![ + EnumVariant::new_with_description("Documented", "Has docs.", 0), + EnumVariant::new("Bare", 1), + EnumVariant::new_with_description("Blank", " ", 2), + ], + ); + let decl = e.to_rust_decl(false, false); + assert!(decl.contains("/// Has docs.")); + assert!( + !decl.lines().any(|l| l.trim() == "///"), + "empty doc comment emitted (clippy::empty_docs):\n{decl}" + ); + assert!(decl.contains("Bare = 1")); + assert!(decl.contains("Blank = 2")); + } + fn test_data() -> Data { let user_info = Type::struct_( "UserInfo",