diff --git a/.changepacks/changepack_log_macro-rest-round7.json b/.changepacks/changepack_log_macro-rest-round7.json new file mode 100644 index 00000000..732982d7 --- /dev/null +++ b/.changepacks/changepack_log_macro-rest-round7.json @@ -0,0 +1 @@ +{"changes":{"crates/vespera/Cargo.toml":"Patch","crates/vespera_core/Cargo.toml":"Patch","crates/vespera_macro/Cargo.toml":"Patch","crates/vespera_inprocess/Cargo.toml":"Patch","crates/vespera_jni/Cargo.toml":"Patch","libs/vespera-bridge/build.gradle.kts":"Patch","libs/vespera-bridge-gradle-plugin/build.gradle.kts":"Patch"},"note":"vespera_macro fixes: schema_type! of a SeaORM Local column with a SQL-function default (NOW()) compiles again; same-named types in sibling inline modules of one file resolve to the right module for schema! / schema_type! / default functions; schema_type! carries serde default values into the derived component; Multipart derives on generic types add the Default bounds their defaults need; a stale route or cron registration is dropped only inside rust-analyzer; two export_app! of one crate with the same name are a compile error; derived garde validation skips serde-skipped fields, does not nest flattened field paths, peels nested Option, keeps bounds beyond 2^53 exact (and loosened to the documented f64 so runtime and OpenAPI agree) and rejects non-finite bounds; a default on a $ref property is documented as a sibling; more literal defaults are resolved (negative numbers, to_owned / into / to_string, String::new / Vec::new, JSON array / object strings); a renamed self-referencing type resolves to its own component; invalid route file name suggestions are valid identifiers; OAuth2 flows reject URL fields they do not take; raw-identifier modules, Path<()> and inline modules in rust-analyzer resolve; use x::{self} aliases resolve for merge; merge route conflicts are checked before any spec file is written; a sidecar spec without a fingerprint falls back to its content hash; escaped braces and percent escapes are one route shape for overlap checks; the cron module-tree scan runs only with the cron feature and ignores a cron attribute imported from another crate.","date":"2026-11-15T00:00:00.000Z"} diff --git a/crates/vespera/tests/derive_garde_wire.rs b/crates/vespera/tests/derive_garde_wire.rs new file mode 100644 index 00000000..629e3599 --- /dev/null +++ b/crates/vespera/tests/derive_garde_wire.rs @@ -0,0 +1,80 @@ +//! `#[derive(Schema)]` validation follows what serde reads from the wire: +//! +//! - a `#[serde(skip)]` / `skip_deserializing` field is never validated +//! (serde fills its default, so its rule used to reject every request); +//! - a `#[serde(flatten)]` + `#[schema(dive)]` field reports the flattened +//! keys at the parent's level (`limit`, not `page.limit`); +//! - every `Option` layer is peeled for an integer `range` (a fractional +//! bound rounds inward), and a type alias keeps its integer bound exact; +//! - an integer bound the `f64` OpenAPI value cannot carry accepts the +//! advertised value too. +#![cfg(feature = "validation")] + +use ::vespera::__validation::garde::Validate; +use ::vespera::Schema; + +fn paths(value: &impl Validate) -> Vec { + value.validate().map_or_else( + |report| report.iter().map(|(path, _)| path.to_string()).collect(), + |()| Vec::new(), + ) +} + +#[derive(Schema, serde::Deserialize, Default)] +pub struct Page { + #[schema(minimum = 1, maximum = 100)] + pub limit: u32, +} + +pub type Count = u64; + +#[derive(Schema, serde::Deserialize)] +pub struct Search { + #[serde(flatten)] + #[schema(dive)] + pub page: Page, + #[serde(skip)] + #[schema(min_length = 1)] + pub cache: String, + #[serde(skip_deserializing)] + #[schema(minimum = 5)] + pub hits: u32, + #[schema(minimum = 0.5)] + pub ratio: Option>, + #[schema(maximum = 3, value_type = u64)] + pub count: Count, + #[schema(minimum = 9_007_199_254_740_993_i64)] + pub big: i64, +} + +fn search(json: &str) -> Search { + serde_json::from_str(json).unwrap() +} + +#[test] +fn validation_follows_the_wire() { + let valid = search(r#"{"limit": 10, "count": 3, "big": 9007199254740993}"#); + assert!(paths(&valid).is_empty(), "{:?}", paths(&valid)); + + // The flattened key reports at the parent's level. + let flat = search(r#"{"limit": 0, "count": 3, "big": 9007199254740993}"#); + assert_eq!(paths(&flat), ["limit"]); + + // `minimum = 0.5` on `Option>` admits 1, not 0. + let ratio = |r: i32| { + search(&format!( + r#"{{"limit": 1, "ratio": {r}, "count": 0, "big": 9007199254740993}}"# + )) + }; + assert_eq!(paths(&ratio(0)), ["ratio"]); + assert_eq!(paths(&ratio(1)), Vec::::new()); + + // The alias keeps its exact integer bound. + let count = search(r#"{"limit": 1, "count": 4, "big": 9007199254740993}"#); + assert_eq!(paths(&count), ["count"]); + + // 2^53 + 1 is advertised as 2^53, which is accepted; below it is not. + let big = |b: i64| search(&format!(r#"{{"limit": 1, "count": 0, "big": {b}}}"#)); + assert_eq!(paths(&big(9_007_199_254_740_992)), Vec::::new()); + assert_eq!(paths(&big(9_007_199_254_740_991)), ["big"]); +} diff --git a/crates/vespera/tests/multipart_serde_defaults.rs b/crates/vespera/tests/multipart_serde_defaults.rs index 3d6f9788..93a19bb9 100644 --- a/crates/vespera/tests/multipart_serde_defaults.rs +++ b/crates/vespera/tests/multipart_serde_defaults.rs @@ -183,6 +183,33 @@ async fn multipart_schema_type_keeps_the_source_defaults_skip_and_strict() { assert_eq!(patch.tags, ["fallback"]); } +/// Generic structs get serde's `Default` bounds: `T: Default` for a field +/// default of type `T` (also a skipped one), `Self: Default` for a container +/// default. +#[derive(Multipart, Debug)] +pub struct GenericField { + #[serde(default)] + pub n: T, + #[serde(skip)] + pub cache: U, +} + +#[derive(Multipart, Debug, Default)] +#[serde(default)] +pub struct GenericContainer { + pub n: T, +} + +#[tokio::test] +async fn generic_defaults_get_default_bounds() { + let field: GenericField = parse(&[]).await.unwrap(); + assert_eq!((field.n, field.cache), (0, 0)); + let container: GenericContainer = parse(&[("n", "4")]).await.unwrap(); + assert_eq!(container.n, 4); + let container: GenericContainer = parse(&[]).await.unwrap(); + assert_eq!(container.n, 0); +} + #[test] fn serde_schema_type_of_a_default_fn_source_compiles_and_defaults() { let view: SourceView = serde_json::from_str("{}").unwrap(); diff --git a/crates/vespera/tests/schema_repeated_inline_names.rs b/crates/vespera/tests/schema_repeated_inline_names.rs new file mode 100644 index 00000000..339b9da4 --- /dev/null +++ b/crates/vespera/tests/schema_repeated_inline_names.rs @@ -0,0 +1,44 @@ +//! Two inline `mod`s of one file each define a `#[derive(Schema)]` struct of +//! the same name whose `#[serde(default = "..")]` names a function of its own +//! module: the derive picks its module by the struct's line, which needs the +//! file re-parsed with real span positions (inside the compiler every +//! re-parsed token points at the call site, so no candidate matched and the +//! default was "not statically determinable" — a compile error). +#![deny(warnings)] + +mod first { + use serde::{Deserialize, Serialize}; + use vespera::Schema; + + fn limit() -> u32 { + 10 + } + + #[derive(Serialize, Deserialize, Clone, Schema)] + pub struct Page { + #[serde(default = "limit")] + pub limit: u32, + } +} + +mod second { + use serde::{Deserialize, Serialize}; + use vespera::Schema; + + fn limit() -> u32 { + 20 + } + + #[derive(Serialize, Deserialize, Clone, Schema)] + pub struct Page { + #[serde(default = "limit")] + pub limit: u32, + } +} + +#[test] +fn each_page_resolves_its_own_default_fn() { + let first: first::Page = serde_json::from_str("{}").unwrap(); + let second: second::Page = serde_json::from_str("{}").unwrap(); + assert_eq!((first.limit, second.limit), (10, 20)); +} diff --git a/crates/vespera_macro/src/collector/module_scope.rs b/crates/vespera_macro/src/collector/module_scope.rs index 13328a96..e999b8af 100644 --- a/crates/vespera_macro/src/collector/module_scope.rs +++ b/crates/vespera_macro/src/collector/module_scope.rs @@ -54,8 +54,9 @@ pub(super) fn module_cfg_predicates(folder_path: &Path, segments: &[String]) -> continue; }; for item in &file.items { + // `pub mod r#type;` declares the `type.rs` segment. if let Item::Mod(module) = item - && module.ident == segment.as_str() + && crate::parser::strip_raw_prefix_owned(module.ident.to_string()) == *segment { predicates.extend(cfg_predicates(&module.attrs)); } @@ -115,23 +116,29 @@ pub(super) fn inline_mod_route_error(file: &Path, (module, handler): &(String, S /// `(module, handler)` of a fast-path `file`'s stored handler declared inside /// an inline module. `#[route]` recorded where each handler sits, so a known /// inline one is rejected without re-parsing; only handlers whose scope could -/// not be located fall back to the source scan. +/// not be located fall back to the source scan. Inside rust-analyzer every +/// handler takes the source scan: the storage still holds the entry of a +/// handler since moved out of its inline module (a different scope, so it +/// was never replaced), which would keep the error alive. pub(super) fn stored_routes_inline_module( file: &Path, stored_routes: &[&crate::route_impl::StoredRouteInfo], ) -> Option<(String, String)> { - if let Some(found) = stored_routes.iter().find_map(|stored| { - stored - .inline_module - .as_ref() - .filter(|module| !module.is_empty()) - .map(|module| (module.clone(), stored.fn_name.clone())) - }) { + let in_ide = crate::macro_storage::in_long_lived_server(); + if !in_ide + && let Some(found) = stored_routes.iter().find_map(|stored| { + stored + .inline_module + .as_ref() + .filter(|module| !module.is_empty()) + .map(|module| (module.clone(), stored.fn_name.clone())) + }) + { return Some(found); } let stored_names: Vec<&str> = stored_routes .iter() - .filter(|stored| stored.inline_module.is_none()) + .filter(|stored| in_ide || stored.inline_module.is_none()) .map(|stored| stored.fn_name.as_str()) .collect(); if stored_names.is_empty() { @@ -173,6 +180,7 @@ mod tests { #[case::two_cfgs("#[cfg(unix)] #[cfg(test)] #[cfg] mod admin;", &["admin"], &["unix", "test"])] #[case::other_module_gated("#[cfg(test)] mod tests; mod admin;", &["admin"], &[])] #[case::folder_root(r#"#[cfg(feature = "x")] mod admin;"#, &[], &[])] + #[case::raw_keyword_module("#[cfg(unix)] pub mod r#type;", &["type"], &["unix"])] fn module_declaration_cfg( #[case] mod_rs: &str, #[case] segments: &[&str], @@ -261,6 +269,28 @@ mod tests { stored_inline_mod_route(&shadowed, &["list", "create"]), Some(("b".to_string(), "create".to_string())) ); + // rust-analyzer still stores `moved` in `mod old` after it moved to + // the top level: the IDE re-reads the file instead of trusting it. + let moved = dir.path().join("moved.rs"); + std::fs::write(&moved, "#[route(get)] pub async fn moved() {}").unwrap(); + file_cache::bump_epoch(); + let stale = crate::route_impl::StoredRouteInfo { + fn_name: "moved".to_string(), + inline_module: Some("old".to_string()), + ..Default::default() + }; + let current = crate::route_impl::StoredRouteInfo { + inline_module: Some(String::new()), + ..stale.clone() + }; + assert!(stored_routes_inline_module(&moved, &[&stale, ¤t]).is_some()); + { + let _ide = crate::macro_storage::test_ide::force(); + assert_eq!( + stored_routes_inline_module(&moved, &[&stale, ¤t]), + None + ); + } let message = inline_mod_route_error(&file, &("v2".to_string(), "list".to_string())); assert!( message.contains("inline module `mod v2 { .. }`"), diff --git a/crates/vespera_macro/src/error.rs b/crates/vespera_macro/src/error.rs index 5a102309..69a68591 100644 --- a/crates/vespera_macro/src/error.rs +++ b/crates/vespera_macro/src/error.rs @@ -10,9 +10,8 @@ //! //! # Key Functions //! -//! - [`err_call_site`] - Create an error at the macro call site -//! - [`err_spanned`] - Create an error at a specific AST node location -//! - [`IntoSynError`] - Convert other error types to `syn::Error` +//! - [`err_call_site`] - Create an error at the macro call site; spanned +//! errors use `syn::Error::new_spanned` directly //! //! # Example //! diff --git a/crates/vespera_macro/src/garde_emit.rs b/crates/vespera_macro/src/garde_emit.rs index 117e44b5..afdc6041 100644 --- a/crates/vespera_macro/src/garde_emit.rs +++ b/crates/vespera_macro/src/garde_emit.rs @@ -130,6 +130,12 @@ fn emit_impl(input: &DeriveInput, field_constraints: &[SchemaConstraints]) -> To .iter() .filter_map(|(field, constraints)| { let ident = field.ident.as_ref()?; + // serde never reads a skipped field (it holds its default): a + // rule on it would reject every request. + let skip = crate::parser::schema::extract_serde_skip_flags(&field.attrs); + if skip.skip || skip.skip_deserializing { + return None; + } let wire_name = crate::parser::extract_field_rename_for( &field.attrs, crate::parser::SerdeSide::Deserialize, @@ -140,7 +146,10 @@ fn emit_impl(input: &DeriveInput, field_constraints: &[SchemaConstraints]) -> To rename_all.as_deref(), ) }); - emit_field_block(ident, &wire_name, field, constraints) + // A flattened field's keys sit at the parent's level on the + // wire, so its errors keep the parent's path. + let flatten = crate::parser::schema::extract_flatten(&field.attrs); + emit_field_block(ident, (&wire_name, flatten), field, constraints) }) .collect(); // Non-empty: the gate above found a field with a runtime rule, and every @@ -176,7 +185,7 @@ fn emit_impl(input: &DeriveInput, field_constraints: &[SchemaConstraints]) -> To #[cfg(feature = "validation")] fn emit_field_block( field_ident: &syn::Ident, - field_name_str: &str, + (field_name_str, flatten): (&str, bool), field: &syn::Field, c: &SchemaConstraints, ) -> Option { @@ -185,22 +194,29 @@ fn emit_field_block( } let field_ty = &field.ty; - let numeric_kind = rust_numeric_kind( - crate::schema_macro::type_utils::option_inner(field_ty).unwrap_or(field_ty), - ); + // Every `Option` layer: `Option>` still ranges as an `i32`. + let mut numeric_ty = field_ty; + while let Some(inner) = crate::schema_macro::type_utils::option_inner(numeric_ty) { + numeric_ty = inner; + } + let numeric_kind = rust_numeric_kind(numeric_ty); let exact = ExactBounds::from_attrs(&field.attrs); let rule_blocks = emit_rule_blocks(c, &exact, field_ident, numeric_kind.as_deref()); // `has_runtime_rule` holds, so at least one of the two is non-empty. let dive_block = emit_dive_block(c); + // The field's own path segment; a flattened field keeps the parent's. + let nest = if flatten { + TokenStream::new() + } else { + quote! { let mut __garde_path = ::vespera::__validation::garde::util::nested_path!(__garde_path, #field_name_str); } + }; let block = if crate::schema_macro::type_utils::is_option_type(field_ty) { // `field_ident` is `&Option` after the `let Self { .. } = self` destructure. // Match ergonomics make `inner` end up as `&T`. quote! { { - let mut __garde_path = ::vespera::__validation::garde::util::nested_path!( - __garde_path, #field_name_str - ); + #nest if let ::std::option::Option::Some(__garde_binding) = #field_ident { #rule_blocks #dive_block @@ -210,9 +226,7 @@ fn emit_field_block( } else { quote! { { - let mut __garde_path = ::vespera::__validation::garde::util::nested_path!( - __garde_path, #field_name_str - ); + #nest let __garde_binding = &*#field_ident; #rule_blocks #dive_block @@ -525,30 +539,86 @@ struct ExactBounds { #[cfg(feature = "validation")] impl ExactBounds { + /// Walks the attributes exactly like `try_extract_schema_constraints` + /// (every key's payload consumed, a `value_type` read as a type, the + /// last `minimum` / `maximum` winning), so the exact bound always + /// belongs to the `f64` the parsed constraints carry. fn from_attrs(attrs: &[syn::Attribute]) -> Self { let mut bounds = Self::default(); for attr in attrs.iter().filter(|attr| attr.path().is_ident("schema")) { - let Ok(metas) = attr.parse_args_with( - syn::punctuated::Punctuated::::parse_terminated, - ) else { - continue; - }; - for meta in metas { - let syn::Meta::NameValue(name_value) = meta else { - continue; - }; - let slot = if name_value.path.is_ident("minimum") { + // The derive already rejected a malformed attribute. + let _ = attr.parse_nested_meta(|meta| { + let slot = if meta.path.is_ident("minimum") { &mut bounds.minimum - } else if name_value.path.is_ident("maximum") { + } else if meta.path.is_ident("maximum") { &mut bounds.maximum } else { - continue; + if meta.path.is_ident("value_type") { + meta.value()?.parse::()?; + } else if meta.input.peek(syn::token::Paren) { + meta.input.parse::()?; + } else if let Ok(value) = meta.value() { + value.parse::()?; + } + return Ok(()); }; - *slot = ExactInt::from_expr(&name_value.value); - } + *slot = ExactInt::from_expr(&meta.value()?.parse()?); + Ok(()) + }); } bounds } + + /// The looser of the exact bound and the documented (`f64`) one `v`, + /// within the type's range: past 2^53 the OpenAPI document can only + /// carry a rounded value, and a client sending that advertised bound + /// must not be rejected. + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + fn loosen( + exact: ExactInt, + v: f64, + bound: Bound, + (type_min, type_max): (i128, u128), + ) -> ExactInt { + // `as` saturates: an out-of-range `f64` lands at the `u128` edge and + // is clamped below. + let doc = ExactInt { + negative: v < 0.0, + magnitude: v.abs() as u128, + }; + let looser = match bound { + Bound::Min => exact.min(doc), + Bound::Max => exact.max(doc), + }; + let lowest = ExactInt { + negative: type_min < 0, + magnitude: type_min.unsigned_abs(), + }; + let highest = ExactInt { + negative: false, + magnitude: type_max, + }; + looser.clamp(lowest, highest) + } +} + +#[cfg(feature = "validation")] +impl Ord for ExactInt { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + match (self.negative, other.negative) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + (false, false) => self.magnitude.cmp(&other.magnitude), + (true, true) => other.magnitude.cmp(&self.magnitude), + } + } +} + +#[cfg(feature = "validation")] +impl PartialOrd for ExactInt { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } } /// Which side of a `range` rule a bound sits on: an integer field rounds a @@ -596,9 +666,25 @@ fn numeric_some( let Some(v) = value else { return Ok(quote! { ::std::option::Option::None }); }; + let key = match bound { + Bound::Min => "minimum", + Bound::Max => "maximum", + }; + if !v.is_finite() { + return Err(format!("`{key}` must be a finite number (got {v})")); + } let Some(kind) = numeric_kind else { - // Unknown numeric kind — last-resort `as _` and let the user - // see a compiler error pointing at their field type. + // Unknown numeric kind (a type alias, a newtype): an integer literal + // stays unsuffixed so the compiler types and range-checks it; + // otherwise last-resort `as _` and let the user see a compiler + // error pointing at their field type. + if let Some(exact) = exact { + let literal: TokenStream = exact + .to_string() + .parse() + .expect("an integer literal is a valid token stream"); + return Ok(quote! { ::std::option::Option::Some(#literal) }); + } return Ok(quote! { ::std::option::Option::Some(#v as _) }); }; let ty_ident = syn::Ident::new(kind, Span::call_site()); @@ -608,10 +694,6 @@ fn numeric_some( if let Some(exact) = exact { // An integer literal bound keeps every digit: the `f64` copy loses // precision past 2^53 (`i64::MAX` would read as 2^63 and "not fit"). - let key = match bound { - Bound::Min => "minimum", - Bound::Max => "maximum", - }; let fits = if exact.negative { exact.magnitude <= type_min.unsigned_abs() } else { @@ -622,6 +704,7 @@ fn numeric_some( "`{key} = {exact}` does not fit the field type `{kind}` (range {type_min}..={type_max})" )); } + let exact = ExactBounds::loosen(exact, v, bound, (type_min, type_max)); let literal: TokenStream = format!("{exact}{kind}") .parse() .expect("an integer literal with a type suffix is a valid token stream"); @@ -629,9 +712,9 @@ fn numeric_some( } // `+ 0.0` turns a `-0.0` (`minimum = -0.5` rounded up) into `0.0`, so no // `-0u32` literal is ever emitted. - let (key, rounded) = match bound { - Bound::Min => ("minimum", v.ceil() + 0.0), - Bound::Max => ("maximum", v.floor() + 0.0), + let rounded = match bound { + Bound::Min => v.ceil() + 0.0, + Bound::Max => v.floor() + 0.0, }; // Every integer type's MIN is a power of two (exact as f64), so the lower // bound compares in the float domain; the upper one compares as `u128` @@ -685,3 +768,5 @@ fn rust_numeric_kind(ty: &Type) -> Option { #[cfg(all(test, feature = "validation"))] mod tests; +#[cfg(all(test, feature = "validation"))] +mod tests_wire; diff --git a/crates/vespera_macro/src/garde_emit/snapshots/vespera_macro__garde_emit__tests__garde_bound_exact_above_2_pow_53.snap b/crates/vespera_macro/src/garde_emit/snapshots/vespera_macro__garde_emit__tests__garde_bound_exact_above_2_pow_53.snap index e349e5b4..9ad15a86 100644 --- a/crates/vespera_macro/src/garde_emit/snapshots/vespera_macro__garde_emit__tests__garde_bound_exact_above_2_pow_53.snap +++ b/crates/vespera_macro/src/garde_emit/snapshots/vespera_macro__garde_emit__tests__garde_bound_exact_above_2_pow_53.snap @@ -30,7 +30,7 @@ impl ::vespera::__validation::garde::Validate for U { if let ::std::result::Result::Err(__garde_error) = (::vespera::__validation::garde::rules::range::apply)( __garde_binding, ( - ::std::option::Option::Some(9007199254740993u64), + ::std::option::Option::Some(9007199254740992u64), ::std::option::Option::None, ), ) { diff --git a/crates/vespera_macro/src/garde_emit/tests.rs b/crates/vespera_macro/src/garde_emit/tests.rs index eb1b831a..bb3cbd3c 100644 --- a/crates/vespera_macro/src/garde_emit/tests.rs +++ b/crates/vespera_macro/src/garde_emit/tests.rs @@ -500,10 +500,11 @@ fn only_maximum_set_emits_none_for_min_bound() { fn minimum_on_non_primitive_field_falls_back_to_as_wildcard() { // Field type is a user-defined `Money` newtype — peel_option // returns None and rust_numeric_kind returns None, forcing - // numeric_some down the `as _` fallback branch. + // numeric_some down the `as _` fallback branch (an integer literal + // stays an unsuffixed literal the compiler types and range-checks). let s: DeriveInput = parse_quote! { struct Order { - #[schema(minimum = 0)] + #[schema(minimum = 0.5)] pub price: Money, } }; @@ -655,7 +656,9 @@ fn integer_range_covers_every_kind(#[case] kind: &str, #[case] expected: Option< } /// Integer-literal bounds keep every digit: through `f64` `i64::MAX` read as -/// 2^63 (a false "does not fit") and `2^53 + 1` lost its last unit. +/// 2^63 (a false "does not fit"). A bound the `f64` OpenAPI value rounds +/// (`2^53 + 1` is documented as 2^53) loosens to the documented value, so a +/// client sending the advertised bound is accepted. #[rstest::rstest] #[case::i64_extremes("garde_bound_exact_i64_extremes", parse_quote! { struct U { #[schema(minimum = -9223372036854775808, maximum = 9223372036854775807)] pub n: i64 } diff --git a/crates/vespera_macro/src/garde_emit/tests_wire.rs b/crates/vespera_macro/src/garde_emit/tests_wire.rs new file mode 100644 index 00000000..06a8b7d4 --- /dev/null +++ b/crates/vespera_macro/src/garde_emit/tests_wire.rs @@ -0,0 +1,102 @@ +//! Validation follows the wire: skipped / flattened fields, every `Option` +//! layer, exact bounds read like the parsed constraints, and the documented +//! (`f64`) value of a bound. +use rstest::rstest; + +use super::*; + +fn emit(src: &str) -> String { + let input: DeriveInput = syn::parse_str(src).unwrap(); + let Data::Struct(data) = &input.data else { + unreachable!() + }; + let constraints: Vec = data + .fields + .iter() + .map(|field| { + crate::parser::schema::schema_attrs::try_extract_schema_constraints(&field.attrs) + .unwrap() + }) + .collect(); + emit_garde_validate(&input, &constraints).to_string() +} + +/// A skipped field gets no block (serde fills its default); a flattened one +/// keeps the parent's path (no `nested_path!` of its own). +#[test] +fn skipped_fields_are_not_validated_and_flatten_keeps_the_path() { + let out = emit( + "struct S { #[serde(skip)] #[schema(min_length = 1)] a: String, \ + #[serde(skip_deserializing)] #[schema(minimum = 1)] b: u32, \ + #[serde(flatten)] #[schema(dive)] c: Page, \ + #[schema(min_length = 1)] d: String }", + ); + assert_eq!(out.matches("nested_path").count(), 1, "{out}"); + assert!(out.contains("\"d\""), "{out}"); + assert!( + !out.contains("\"a\"") && !out.contains("\"b\"") && !out.contains("\"c\""), + "{out}" + ); + assert_eq!( + out.matches("validate_into (& * __garde_binding").count(), + 1, + "{out}" + ); +} + +/// The rendered `(min, max)` range bounds of one field. +#[rstest] +#[case::nested_option("#[schema(minimum = 0.5)] f: Option>", "Some (1i32)")] +#[case::alias_exact("#[schema(maximum = 3)] f: Count", "Some (3)")] +#[case::alias_fraction("#[schema(maximum = 2.5)] f: Count", "Some (2.5f64 as _)")] +#[case::past_2_pow_53( + "#[schema(minimum = 9007199254740993)] f: i64", + "Some (9007199254740992i64)" +)] +#[case::negative_min_keeps_exact( + "#[schema(minimum = -9007199254740993)] f: i64", + "Some (- 9007199254740993i64)" +)] +#[case::negative_max_loosens( + "#[schema(maximum = -9007199254740993)] f: i64", + "Some (- 9007199254740992i64)" +)] +#[case::i64_max_clamped( + "#[schema(maximum = 9223372036854775807)] f: i64", + "Some (9223372036854775807i64)" +)] +#[case::i64_min( + "#[schema(minimum = -9223372036854775808)] f: i64", + "Some (- 9223372036854775808i64)" +)] +#[case::u64_max_clamped( + "#[schema(maximum = 18446744073709551615)] f: u64", + "Some (18446744073709551615u64)" +)] +#[case::value_type_attr_not_stale( + "#[schema(minimum = 9007199254740993)] #[schema(value_type = Vec, minimum = 3)] f: i64", + "Some (3i64)" +)] +#[case::last_attr_wins( + "#[schema(minimum = 9007199254740993)] #[schema(minimum = 2.5)] f: i64", + "Some (3i64)" +)] +#[case::group_and_flag( + "#[schema(dive, foo(bar), name = \"x\", minimum = 7)] f: u8", + "Some (7u8)" +)] +fn range_bounds_follow_the_document(#[case] field: &str, #[case] expected: &str) { + let out = emit(&format!("struct S {{ {field} }}")); + assert!(out.contains(expected), "{out}"); +} + +/// A bound `f64` cannot hold (`1e400` parses to infinity) is a spanned +/// compile error, not a `quote!` panic. +#[rstest] +#[case::float_field("#[schema(maximum = 1e400)] f: f64")] +#[case::unknown_type("#[schema(minimum = -1e400)] f: Count")] +fn non_finite_bound_is_a_compile_error(#[case] field: &str) { + let out = emit(&format!("struct S {{ {field} }}")); + assert!(out.contains("compile_error"), "{out}"); + assert!(out.contains("must be a finite number"), "{out}"); +} diff --git a/crates/vespera_macro/src/macro_storage.rs b/crates/vespera_macro/src/macro_storage.rs index 5801b6ed..4995e8e5 100644 --- a/crates/vespera_macro/src/macro_storage.rs +++ b/crates/vespera_macro/src/macro_storage.rs @@ -92,19 +92,76 @@ pub fn current_crate_items(storage: &CrateStorage) -> Arc> { /// [`register`] replaces an entry only when the SAME function is expanded /// again, so in a long-lived rust-analyzer proc-macro server a renamed or /// deleted handler / job leaves its old entry behind — and `vespera!` would -/// mount a function that no longer exists. Entries are checked against the -/// current file text (the fingerprint-validated `file_cache`, so a file is -/// read once per change); one without a file path or whose file cannot be -/// read is kept. Borrows when nothing is stale — always under rustc, whose -/// storage only ever holds the current compilation. +/// mount a function that no longer exists. Inside that server, entries are +/// checked against the current file text (the fingerprint-validated +/// `file_cache`, so a file is read once per change); one without a file path +/// or whose file cannot be read is kept. Under rustc the storage only ever +/// holds the current compilation, so nothing is filtered: a handler / job a +/// `macro_rules!` macro generates (`h!(list)`) has no literal `fn list` in +/// its file and must not be dropped. Borrows when nothing is stale. pub fn retain_declared(items: &[T]) -> std::borrow::Cow<'_, [T]> { + retain_declared_if(items, in_long_lived_server()) +} + +/// Whether this process is rust-analyzer's long-lived proc-macro server, +/// whose storage keeps entries of edited-away items. +pub fn in_long_lived_server() -> bool { + static IN_IDE: std::sync::OnceLock = std::sync::OnceLock::new(); + *IN_IDE.get_or_init(|| is_rust_analyzer(std::env::current_exe().ok().as_deref())) + || test_ide::forced() +} + +/// Test-only switch emulating the IDE for [`retain_declared`]. +#[cfg(test)] +pub mod test_ide { + use std::cell::Cell; + + thread_local! { + static FORCED: Cell = const { Cell::new(false) }; + } + + pub fn forced() -> bool { + FORCED.with(Cell::get) + } + + /// Emulates rust-analyzer on this thread until the guard drops. + pub fn force() -> impl Drop { + struct Restore; + impl Drop for Restore { + fn drop(&mut self) { + FORCED.with(|forced| forced.set(false)); + } + } + FORCED.with(|forced| forced.set(true)); + Restore + } +} + +#[cfg(not(test))] +mod test_ide { + pub(super) const fn forced() -> bool { + false + } +} + +/// Whether `exe` is rust-analyzer's (long-lived) proc-macro server. +pub fn is_rust_analyzer(exe: Option<&std::path::Path>) -> bool { + exe.and_then(std::path::Path::file_name) + .is_some_and(|name| name.to_string_lossy().contains("rust-analyzer")) +} + +/// [`retain_declared`], filtering only when `filter` (inside the IDE). +fn retain_declared_if( + items: &[T], + filter: bool, +) -> std::borrow::Cow<'_, [T]> { let declared = |item: &T| { item.file_path().is_none_or(|file| { crate::schema_macro::file_cache::get_file_content(std::path::Path::new(file)) .is_none_or(|content| declares_fn(&content, item.fn_name())) }) }; - if items.iter().all(declared) { + if !filter || items.iter().all(declared) { std::borrow::Cow::Borrowed(items) } else { std::borrow::Cow::Owned( @@ -178,12 +235,26 @@ mod tests { ..entry("pathless", "", "pathless") }, ]; - let kept = retain_declared(&items); + let kept = retain_declared_if(&items, true); assert!(matches!(kept, std::borrow::Cow::Owned(_))); let payloads: Vec<_> = kept.iter().map(|item| item.payload).collect(); assert_eq!(payloads, ["live", "unreadable", "pathless"]); - let live = retain_declared(&items[1..]); + let live = retain_declared_if(&items[1..], true); assert!(matches!(live, std::borrow::Cow::Borrowed(_))); + // Outside the IDE (rustc, this test binary) nothing is filtered: a + // `macro_rules!`-generated function has no literal `fn name`. + assert!( + matches!(retain_declared(&items), std::borrow::Cow::Borrowed(all) if all.len() == 4) + ); + } + + #[rstest::rstest] + #[case::server(Some("rust-analyzer-proc-macro-srv"), true)] + #[case::exe(Some("rust-analyzer.exe"), true)] + #[case::rustc(Some("rustc"), false)] + #[case::unknown(None, false)] + fn is_rust_analyzer_matches_the_server_exe(#[case] exe: Option<&str>, #[case] expected: bool) { + assert_eq!(is_rust_analyzer(exe.map(std::path::Path::new)), expected); } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/vespera_macro/src/multipart_impl/fields.rs b/crates/vespera_macro/src/multipart_impl/fields.rs index c913e334..9972ebc7 100644 --- a/crates/vespera_macro/src/multipart_impl/fields.rs +++ b/crates/vespera_macro/src/multipart_impl/fields.rs @@ -19,6 +19,9 @@ pub(super) struct FieldCodegen<'a> { /// The type each parsed (non-skipped) field is read as — the /// `TryFromFieldWithState` bound a generic struct's impl needs. pub(super) parsed_types: Vec, + /// The type of each field filled from `Default::default()` — the + /// `Default` bound a generic struct's impl needs (serde infers it too). + pub(super) default_types: Vec, /// Malformed `#[form_data(...)]` attributes. pub(super) errors: Vec, } @@ -51,6 +54,7 @@ pub(super) fn process_fields<'a>( post_loop: Vec::new(), idents: Vec::new(), parsed_types: Vec::new(), + default_types: Vec::new(), errors: Vec::new(), }; // Wire name -> the field that claimed it first: two fields resolving to @@ -79,6 +83,7 @@ pub(super) fn process_fields<'a>( DefaultKind::Function(fn_path) => default_fn_call(ident, fn_path, locals), DefaultKind::Container => quote! { __vespera_default__.#ident }, DefaultKind::Trait | DefaultKind::None => { + cg.default_types.push(ty.clone()); quote! { ::core::default::Default::default() } } }; @@ -209,6 +214,9 @@ fn push_post_loop<'a>( return; } + if matches!(default_kind, DefaultKind::Trait) { + cg.default_types.push(ty.clone()); + } cg.post_loop.push(post_loop_binding( ident, ty, diff --git a/crates/vespera_macro/src/multipart_impl/mod.rs b/crates/vespera_macro/src/multipart_impl/mod.rs index 60090675..34dea407 100644 --- a/crates/vespera_macro/src/multipart_impl/mod.rs +++ b/crates/vespera_macro/src/multipart_impl/mod.rs @@ -79,7 +79,9 @@ pub fn process_derive(input: &DeriveInput) -> TokenStream { // serde's container default: bound once after the read loop (it is not // held across an `.await`), every field without a default of its own // takes its value from it. - let container_default = match extract_struct_default(&input.attrs) { + let struct_default_kind = extract_struct_default(&input.attrs); + let container_trait_default = matches!(struct_default_kind, DefaultKind::Trait); + let container_default = match struct_default_kind { DefaultKind::Trait => Some(quote! { ::core::default::Default::default() }), DefaultKind::Function(fn_path) => Some(default_fn_call(struct_name, &fn_path, &locals)), DefaultKind::None | DefaultKind::Container => None, @@ -101,7 +103,8 @@ pub fn process_derive(input: &DeriveInput) -> TokenStream { }) { return err.to_compile_error(); } - let (impl_generics, ty_generics, where_clause) = impl_generics(input, &cg.parsed_types); + let (impl_generics, ty_generics, where_clause) = + impl_generics(input, &cg, container_trait_default); // Wildcard arm of the field-dispatch `match`: strict mode rejects an // unknown field name; non-strict ignores it. Replaces the trailing @@ -195,7 +198,8 @@ pub fn process_derive(input: &DeriveInput) -> TokenStream { /// and a `TryFromFieldWithState` bound on each parsed type that mentions one. fn impl_generics( input: &DeriveInput, - parsed_types: &[syn::Type], + cg: &fields::FieldCodegen<'_>, + container_trait_default: bool, ) -> (TokenStream, TokenStream, TokenStream) { let mut generics = input.generics.clone(); let type_params: Vec = generics.type_params().map(|p| p.ident.clone()).collect(); @@ -208,16 +212,31 @@ fn impl_generics( .predicates .push(syn::parse_quote!(#ident: Send)); } - let mut seen = std::collections::HashSet::new(); - for ty in parsed_types.iter().filter(|ty| { + let (_, ty_generics, _) = input.generics.split_for_impl(); + let generic = |ty: &&syn::Type, seen: &mut std::collections::HashSet| { mentions_any(quote!(#ty), &type_params) && seen.insert(quote!(#ty).to_string()) - }) { + }; + let mut seen = std::collections::HashSet::new(); + for ty in cg.parsed_types.iter().filter(|ty| generic(ty, &mut seen)) { where_clause.predicates.push(syn::parse_quote!( #ty: vespera::multipart::TryFromFieldWithState<__VesperaS__> )); } + // serde's inferred bounds: a defaulted field of a generic type, and the + // container default of a generic struct. + let mut seen = std::collections::HashSet::new(); + for ty in cg.default_types.iter().filter(|ty| generic(ty, &mut seen)) { + where_clause + .predicates + .push(syn::parse_quote!(#ty: ::core::default::Default)); + } + if container_trait_default && !type_params.is_empty() { + let name = &input.ident; + where_clause + .predicates + .push(syn::parse_quote!(#name #ty_generics: ::core::default::Default)); + } let (impl_generics, _, where_clause) = generics.split_for_impl(); - let (_, ty_generics, _) = input.generics.split_for_impl(); ( quote!(#impl_generics), quote!(#ty_generics), @@ -563,6 +582,16 @@ mod tests { "multipart_generic_where_clause", "struct F where T: Default { #[serde(default)] pub value: Option }" )] + // serde's inferred `Default` bounds: once per generic defaulted type + // (a skipped field too), never for a concrete one. + #[case::default_bounds( + "multipart_generic_default_bounds", + "struct F { #[serde(default)] pub n: T, #[serde(default)] pub m: T, #[serde(skip)] pub cache: U, #[serde(default)] pub name: String }" + )] + #[case::container_default( + "multipart_generic_container_default", + "#[serde(default)] struct F { pub n: T }" + )] fn generic_structs_are_supported(#[case] name: &str, #[case] src: &str) { insta::assert_snapshot!(name, derive_pretty(src)); } diff --git a/crates/vespera_macro/src/multipart_impl/snapshots/vespera_macro__multipart_impl__tests__multipart_generic_container_default.snap b/crates/vespera_macro/src/multipart_impl/snapshots/vespera_macro__multipart_impl__tests__multipart_generic_container_default.snap new file mode 100644 index 00000000..0bdab03a --- /dev/null +++ b/crates/vespera_macro/src/multipart_impl/snapshots/vespera_macro__multipart_impl__tests__multipart_generic_container_default.snap @@ -0,0 +1,53 @@ +--- +source: crates/vespera_macro/src/multipart_impl/mod.rs +expression: derive_pretty(src) +--- +impl< + T, + __VesperaS__: Send + Sync, +> vespera::multipart::TryFromMultipartWithState<__VesperaS__> for F +where + T: Send, + T: vespera::multipart::TryFromFieldWithState<__VesperaS__>, + F: ::core::default::Default, +{ + async fn try_from_multipart_with_state( + __multipart__: &mut vespera::axum::extract::Multipart, + __state__: &__VesperaS__, + ) -> std::result::Result { + let mut n: std::option::Option = std::option::Option::None; + while let std::option::Option::Some(__field__) = __multipart__ + .next_field() + .await + .map_err(vespera::multipart::TypedMultipartError::from)? + { + vespera::multipart::register_multipart_part()?; + let __field__ = vespera::multipart::MeteredField::__from_field(__field__); + let __field_name__ = match __field__.name() { + std::option::Option::Some("") | std::option::Option::None => continue, + std::option::Option::Some(__name__) => __name__, + }; + match __field_name__ { + "n" => { + n = std::option::Option::Some( + >::try_from_field_with_state( + __field__, + std::option::Option::None, + __state__, + ) + .await?, + ); + } + _ => {} + } + } + let __vespera_default__: Self = ::core::default::Default::default(); + let n: T = match n { + std::option::Option::Some(__vespera_value__) => __vespera_value__, + std::option::Option::None => __vespera_default__.n, + }; + std::result::Result::Ok(Self { n }) + } +} diff --git a/crates/vespera_macro/src/multipart_impl/snapshots/vespera_macro__multipart_impl__tests__multipart_generic_default_bounds.snap b/crates/vespera_macro/src/multipart_impl/snapshots/vespera_macro__multipart_impl__tests__multipart_generic_default_bounds.snap new file mode 100644 index 00000000..9083ee28 --- /dev/null +++ b/crates/vespera_macro/src/multipart_impl/snapshots/vespera_macro__multipart_impl__tests__multipart_generic_default_bounds.snap @@ -0,0 +1,81 @@ +--- +source: crates/vespera_macro/src/multipart_impl/mod.rs +expression: derive_pretty(src) +--- +impl< + T, + U, + __VesperaS__: Send + Sync, +> vespera::multipart::TryFromMultipartWithState<__VesperaS__> for F +where + T: Send, + U: Send, + T: vespera::multipart::TryFromFieldWithState<__VesperaS__>, + T: ::core::default::Default, + U: ::core::default::Default, +{ + async fn try_from_multipart_with_state( + __multipart__: &mut vespera::axum::extract::Multipart, + __state__: &__VesperaS__, + ) -> std::result::Result { + let mut n: std::option::Option = std::option::Option::None; + let mut m: std::option::Option = std::option::Option::None; + let mut name: std::option::Option = std::option::Option::None; + while let std::option::Option::Some(__field__) = __multipart__ + .next_field() + .await + .map_err(vespera::multipart::TypedMultipartError::from)? + { + vespera::multipart::register_multipart_part()?; + let __field__ = vespera::multipart::MeteredField::__from_field(__field__); + let __field_name__ = match __field__.name() { + std::option::Option::Some("") | std::option::Option::None => continue, + std::option::Option::Some(__name__) => __name__, + }; + match __field_name__ { + "n" => { + n = std::option::Option::Some( + >::try_from_field_with_state( + __field__, + std::option::Option::None, + __state__, + ) + .await?, + ); + } + "m" => { + m = std::option::Option::Some( + >::try_from_field_with_state( + __field__, + std::option::Option::None, + __state__, + ) + .await?, + ); + } + "name" => { + name = std::option::Option::Some( + >::try_from_field_with_state( + __field__, + std::option::Option::None, + __state__, + ) + .await?, + ); + } + _ => {} + } + } + let n: T = n.unwrap_or_default(); + let m: T = m.unwrap_or_default(); + let cache: U = ::core::default::Default::default(); + let name: String = name.unwrap_or_default(); + std::result::Result::Ok(Self { n, m, cache, name }) + } +} diff --git a/crates/vespera_macro/src/openapi_generator.rs b/crates/vespera_macro/src/openapi_generator.rs index c8618279..53f3a39c 100644 --- a/crates/vespera_macro/src/openapi_generator.rs +++ b/crates/vespera_macro/src/openapi_generator.rs @@ -28,6 +28,7 @@ use component_schemas::{ pub use defaults::extract_default_value_from_function; #[cfg(test)] pub use defaults::find_function_in_file; +pub use defaults::process_default_functions_for; use paths::build_path_items; pub use paths::{check_duplicate_routes, find_duplicate_operation_id}; pub use route_claims::{RouteClaims, RouteConflict}; diff --git a/crates/vespera_macro/src/openapi_generator/component_schemas.rs b/crates/vespera_macro/src/openapi_generator/component_schemas.rs index 3fe8e33c..9ce6308f 100644 --- a/crates/vespera_macro/src/openapi_generator/component_schemas.rs +++ b/crates/vespera_macro/src/openapi_generator/component_schemas.rs @@ -396,6 +396,25 @@ pub(super) fn parse_component_schemas( ), ) })?; + // A renamed type naming its own Rust ident (`children: Vec`) + // means itself, not whichever same-ident rename the shared alias + // resolves to (`mod a` / `mod b` both `Node`). + let own_definitions: HashMap<&str, &str>; + let struct_definitions = match struct_meta.rust_ident.as_deref() { + Some(ident) + if struct_definitions + .get(ident) + .is_some_and(|definition| *definition != struct_meta.definition) => + { + own_definitions = struct_definitions + .iter() + .map(|(key, definition)| (*key, *definition)) + .chain([(ident, struct_meta.definition.as_str())]) + .collect(); + &own_definitions + } + _ => struct_definitions, + }; // One view of the item: the response view, or (inside // `with_request_view`) the request view with deserialize names. let build_view = |side: SerdeSide| { diff --git a/crates/vespera_macro/src/openapi_generator/component_schemas/tests.rs b/crates/vespera_macro/src/openapi_generator/component_schemas/tests.rs index 980c9bf6..9ad4c153 100644 --- a/crates/vespera_macro/src/openapi_generator/component_schemas/tests.rs +++ b/crates/vespera_macro/src/openapi_generator/component_schemas/tests.rs @@ -562,6 +562,38 @@ fn ambiguous_alias_used_by_a_parsed_route_file() { assert!(check_ambiguous_aliases(&metadata, &file_cache, &[]).is_err()); } +/// Two renamed types sharing a Rust ident each reference THEMSELVES through +/// that ident (`TreeB.children` used to `$ref` the first-sorted `TreeA`). +#[test] +fn renamed_self_reference_resolves_to_its_own_component() { + let tree = |name: &str| { + renamed( + name, + "Node", + &format!("#[schema(name = \"{name}\")] struct Node {{ children: Vec }}"), + ) + }; + let mut metadata = CollectedMetadata::new(); + metadata.structs = vec![tree("TreeA"), tree("TreeB")]; + let (known, definitions) = build_schema_lookups(&metadata, &[]); + let schemas = parse_component_schemas( + &metadata, + &known, + &definitions, + &HashMap::new(), + &HashMap::new(), + ) + .unwrap(); + for name in ["TreeA", "TreeB"] { + let json = serde_json::to_value(&schemas[name]).unwrap(); + assert_eq!( + json["properties"]["children"]["items"]["$ref"], + format!("#/components/schemas/{name}"), + "{json}" + ); + } +} + #[test] fn struct_file_index_is_path_ordered() { let file_cache = HashMap::from([ diff --git a/crates/vespera_macro/src/openapi_generator/defaults.rs b/crates/vespera_macro/src/openapi_generator/defaults.rs index 9f918786..46fb7e96 100644 --- a/crates/vespera_macro/src/openapi_generator/defaults.rs +++ b/crates/vespera_macro/src/openapi_generator/defaults.rs @@ -17,17 +17,30 @@ use crate::{ schema_macro::type_utils::get_type_default as utils_get_type_default, }; -/// Set the default value on an inline property schema, if not already set. +/// Set the default value on a property schema, if not already set. /// -/// Looks up `field_name` in the properties map. If found as an inline schema -/// and the schema has no existing default, sets `value` as the default. +/// Looks up `field_name` in the properties map. An inline schema without a +/// default gets `value`; a pure `$ref` becomes `{"$ref": .., "default": ..}` +/// (OpenAPI 3.1 allows siblings of `$ref`), so an explicit +/// `#[schema(default = "active")]` on an enum-typed field is documented. pub(super) fn set_property_default( properties: &mut BTreeMap, field_name: &str, value: serde_json::Value, ) { - use vespera_core::schema::{SchemaRef, SchemaType}; + use vespera_core::schema::{Schema, SchemaRef, SchemaType}; + if let Some(property) = properties.get_mut(field_name) + && let SchemaRef::Ref(reference) = property + { + let ref_path = std::mem::take(&mut reference.ref_path); + *property = SchemaRef::Inline(Box::new(Schema { + ref_path: Some(ref_path), + default: Some(value), + ..Schema::default() + })); + return; + } if let Some(SchemaRef::Inline(prop_schema)) = properties.get_mut(field_name) && prop_schema.default.is_none() { @@ -69,7 +82,7 @@ pub(super) fn process_default_functions( /// [`process_default_functions`] for the `side` view of the struct: the /// request (`{Name}Input`) view keys its properties by the deserialize name. -pub(super) fn process_default_functions_for( +pub fn process_default_functions_for( struct_item: &syn::ItemStruct, file_ast: Option<&syn::File>, schema: &mut vespera_core::schema::Schema, @@ -249,8 +262,15 @@ pub(super) fn extract_schema_default_attr(attrs: &[syn::Attribute]) -> Option serde_json::Value { + // A JSON array / object (`"[]"`, `"{"a": 1}"`) for a non-string field. + if let Ok(json @ (serde_json::Value::Array(_) | serde_json::Value::Object(_))) = + serde_json::from_str::(value) + { + return json; + } // Try integer first if let Ok(n) = value.parse::() { return serde_json::Value::Number(n.into()); @@ -326,6 +346,42 @@ pub fn extract_default_value_from_function(func: &syn::ItemFn) -> Option Option { + match extract_value_from_expr(expr)? { + serde_json::Value::Number(n) => n + .as_i64() + .and_then(i64::checked_neg) + .map(serde_json::Value::from) + .or_else(|| { + n.as_f64() + .and_then(|v| serde_json::Number::from_f64(-v)) + .map(serde_json::Value::Number) + }), + _ => None, + } +} + +/// The value of an argument-less conversion of a literal: `to_owned` / +/// `into` keep a string, `to_string` also renders a number or bool as the +/// STRING (`42.to_string()` is `"42"`). +fn method_call_value(call: &syn::ExprMethodCall) -> Option { + use serde_json::Value; + if !call.args.is_empty() { + return None; + } + match ( + call.method.to_string().as_str(), + extract_value_from_expr(&call.receiver)?, + ) { + ("to_owned" | "into" | "to_string", value @ Value::String(_)) => Some(value), + ("to_string", value @ (Value::Number(_) | Value::Bool(_))) => { + Some(Value::String(value.to_string())) + } + _ => None, + } +} + /// Extract value from expression pub(super) fn extract_value_from_expr(expr: &syn::Expr) -> Option { use syn::{Expr, ExprLit, ExprMacro, Lit}; @@ -346,24 +402,14 @@ pub(super) fn extract_value_from_expr(expr: &syn::Expr) -> Option Some(serde_json::Value::Bool(b.value)), _ => None, }, - // Method calls like "value".to_string() - Expr::MethodCall(method_call) => { - if method_call.method == "to_string" { - // Get the receiver (the string literal) - // Try direct match first - if let Expr::Lit(ExprLit { - lit: Lit::Str(s), .. - }) = method_call.receiver.as_ref() - { - return Some(serde_json::Value::String(s.value())); - } - // Try to extract from nested expressions (e.g., if the receiver is wrapped) - if let Some(value) = extract_value_from_expr(method_call.receiver.as_ref()) { - return Some(value); - } - } - None - } + // `-1` / `-0.5`: a negated numeric literal. + Expr::Unary(syn::ExprUnary { + op: syn::UnOp::Neg(_), + expr, + .. + }) => negated_value(expr), + // `"value".to_string()` / `.to_owned()` / `.into()`, `42.to_string()` + Expr::MethodCall(method_call) => method_call_value(method_call), // Associated function calls like String::from("value") Expr::Call(call) => { let Expr::Path(path) = call.func.as_ref() else { @@ -372,6 +418,14 @@ pub(super) fn extract_value_from_expr(expr: &syn::Expr) -> Option Some(serde_json::Value::String(String::new())), + "Vec" => Some(serde_json::Value::Array(Vec::new())), + _ => None, + }; + } if last.ident == "from" && prev.ident == "String" && call.args.len() == 1 diff --git a/crates/vespera_macro/src/openapi_generator/defaults/tests.rs b/crates/vespera_macro/src/openapi_generator/defaults/tests.rs index 95029845..bebc7c09 100644 --- a/crates/vespera_macro/src/openapi_generator/defaults/tests.rs +++ b/crates/vespera_macro/src/openapi_generator/defaults/tests.rs @@ -32,7 +32,21 @@ fn parse_type(src: &str) -> syn::Type { #[case::vec_macro_nonempty("vec![1, 2, 3]", Some(json!([1, 2, 3])))] #[case::vec_macro_strings(r#"vec!["a", "b"]"#, Some(json!(["a", "b"])))] #[case::vec_macro_unresolvable("vec![some_var]", None)] -#[case::int_to_string("42.to_string()", Some(Value::Number(42.into())))] +#[case::int_to_string("42.to_string()", Some(json!("42")))] +// `Vec` has no `Display`: not a default the code could compile with. +#[case::vec_of_strings_to_string(r#"vec!["a"].to_string()"#, None)] +#[case::negative_int("-1", Some(json!(-1)))] +#[case::negative_float("-0.5", Some(json!(-0.5)))] +#[case::negative_non_number("-true", None)] +#[case::negative_unresolvable("-x", None)] +#[case::negative_i64_min("-9223372036854775808", None)] +#[case::to_owned(r#""x".to_owned()"#, Some(json!("x")))] +#[case::into(r#""x".into()"#, Some(json!("x")))] +#[case::into_non_literal("x.into()", None)] +#[case::conversion_with_arguments(r#""x".into(1)"#, None)] +#[case::string_new("String::new()", Some(json!("")))] +#[case::vec_new("Vec::new()", Some(json!([])))] +#[case::other_new("HashMap::new()", None)] #[case::binary_unsupported("1 + 2", None)] #[case::method_call_non_to_string(r#""hello".len()"#, None)] #[case::byte_lit_unsupported("b'a'", None)] @@ -235,6 +249,9 @@ fn extract_schema_default_attr_cases( #[case::float("2.72", json!(2.72))] #[case::bool("true", json!(true))] #[case::string_fallback("hello world", json!("hello world"))] +#[case::empty_array("[]", json!([]))] +#[case::object(r#"{"a": 1}"#, json!({"a": 1}))] +#[case::json_scalar_keeps_old_rules("null", json!("null"))] fn parse_default_string_to_json_value_cases(#[case] input: &str, #[case] expected: Value) { assert_eq!(parse_default_string_to_json_value(input), expected); } @@ -274,17 +291,21 @@ fn set_property_default_does_not_overwrite_existing() { assert_inline_default(&properties, "name", &json!("existing")); } +/// A `$ref` property keeps its reference and gains a sibling `default`. #[test] -fn set_property_default_skips_ref_schema() { +fn set_property_default_documents_a_ref_schema() { let mut properties = BTreeMap::new(); properties.insert( - "user".to_string(), - SchemaRef::Ref(Reference::schema("User")), + "status".to_string(), + SchemaRef::Ref(Reference::schema("Status")), ); - set_property_default(&mut properties, "user", json!("ignored")); + set_property_default(&mut properties, "status", json!("active")); - assert!(matches!(properties.get("user"), Some(SchemaRef::Ref(_)))); + assert_eq!( + serde_json::to_value(&properties["status"]).unwrap(), + json!({"$ref": "#/components/schemas/Status", "default": "active"}) + ); } #[test] diff --git a/crates/vespera_macro/src/openapi_generator/route_claims.rs b/crates/vespera_macro/src/openapi_generator/route_claims.rs index 9b637ed1..a1f77ed0 100644 --- a/crates/vespera_macro/src/openapi_generator/route_claims.rs +++ b/crates/vespera_macro/src/openapi_generator/route_claims.rs @@ -22,14 +22,16 @@ use std::collections::HashMap; use crate::parser::openapi_path_template; /// The shape of an axum path (`{name}` → `{}`, `{*name}` → `{*}`) and its -/// parameter names in order. `{{` / `}}` escapes stay literal. +/// parameter names in order. `{{` / `}}` escapes become `%7B` / `%7D`, the +/// spelling a merged child's OpenAPI template carries. pub fn path_shape(path: &str) -> (String, Vec) { let mut shape = String::with_capacity(path.len()); let mut names = Vec::new(); let mut chars = path.chars().peekable(); while let Some(c) = chars.next() { match c { - '{' if chars.next_if_eq(&'{').is_some() => shape.push_str("{{"), + '{' if chars.next_if_eq(&'{').is_some() => shape.push_str("%7B"), + '}' if chars.next_if_eq(&'}').is_some() => shape.push_str("%7D"), '{' => { let catch_all = chars.next_if_eq(&'*').is_some(); names.push(chars.by_ref().take_while(|&c| c != '}').collect()); @@ -64,10 +66,6 @@ fn wildcard_positions(shape: &str, template: bool) -> Option { - (tuple.elems.len() < params.len()).then(|| extracts(tuple.elems.len())) - } + // `Path<()>` deserializes as a unit (axum ignores the parameters). + Type::Tuple(tuple) => (!tuple.elems.is_empty() && tuple.elems.len() < params.len()) + .then(|| extracts(tuple.elems.len())), Type::Paren(paren) => mismatch(&paren.elem, params, route_path, lookup), _ if is_primitive_type(inner) => (params.len() >= 2).then(|| extracts(1)), Type::Path(type_path) @@ -261,6 +261,8 @@ mod tests { #[case::qualified(Some("/{id}"), "p: axum::extract::Path")] #[case::tuple_exact(Some("/{a}/{b}"), "Path((a, b)): Path<(String, u32)>")] #[case::one_tuple(Some("/{a}"), "p: Path<(u32,)>")] + // `Path<()>` deserializes as a unit whatever the parameters. + #[case::unit(Some("/{id}"), "_p: Path<()>")] #[case::validated_tuple(Some("/{a}/{b}"), "p: Validated>")] #[case::axum_07(Some("/:a/{*b}"), "p: Path<(String, String)>")] #[case::map(Some("/{a}/{b}"), "p: Path>")] diff --git a/crates/vespera_macro/src/router_codegen/generator.rs b/crates/vespera_macro/src/router_codegen/generator.rs index 7fb432c5..791fb54e 100644 --- a/crates/vespera_macro/src/router_codegen/generator.rs +++ b/crates/vespera_macro/src/router_codegen/generator.rs @@ -21,6 +21,20 @@ fn segment_ident(name: &str) -> Option { Some(ident) } +/// A snake_case identifier to rename the invalid segment `name` to: other +/// characters become `_`, a leading digit gets a `_` prefix (`2fa` → +/// `_2fa`), and a name still no identifier (`self`, `_`) gets a `_` suffix. +fn suggested_ident(name: &str) -> String { + let mut suggestion = name.replace(|c: char| !c.is_ascii_alphanumeric(), "_"); + if suggestion.starts_with(|c: char| c.is_ascii_digit()) { + suggestion.insert(0, '_'); + } + if segment_ident(&suggestion).is_none() { + suggestion.push('_'); + } + suggestion +} + /// Build `crate::::` as a `syn::Path`. /// Empty segments inside `module_path` (from accidental `::` doubling) /// are dropped, matching the existing behaviour of both call sites. @@ -46,7 +60,7 @@ fn crate_path_to( let Some(ident) = segment_ident(name) else { let message = format!( "vespera: '{file_path}' maps to the module path segment `{name}`, which is not a valid Rust identifier. Rename the file (or folder) to snake_case, e.g. `{}`.", - name.replace(|c: char| !c.is_ascii_alphanumeric(), "_") + suggested_ident(name) ); return Err(syn::Error::new(Span::call_site(), message).to_compile_error()); }; diff --git a/crates/vespera_macro/src/router_codegen/generator/tests.rs b/crates/vespera_macro/src/router_codegen/generator/tests.rs index a0e3c68d..486501d9 100644 --- a/crates/vespera_macro/src/router_codegen/generator/tests.rs +++ b/crates/vespera_macro/src/router_codegen/generator/tests.rs @@ -437,8 +437,10 @@ pub fn get_users() -> String { // Check route is present assert!(code.contains("get_users")); - // Module path should not have double colons - assert!(!code.contains("::users::users")); + // An empty folder name adds no segment: the handler is `crate::users::get_users`. + let compact: String = code.split_whitespace().collect(); + assert!(compact.contains("crate::users::get_users"), "{code}"); + assert!(!compact.contains("::::"), "{code}"); drop(temp_dir); } @@ -723,9 +725,9 @@ fn test_generate_router_code_with_both_docs_and_merge() { "Should have at least 2 MERGED_SPEC for docs and redoc, got: {merged_spec_count}" ); // __VESPERA_SPEC should appear exactly once (the const declaration) - let vespera_spec_count = code.matches("__VESPERA_SPEC").count(); - assert!( - vespera_spec_count >= 1, + let vespera_spec_count = code.matches("const __VESPERA_SPEC").count(); + assert_eq!( + vespera_spec_count, 1, "Should have __VESPERA_SPEC const, got: {vespera_spec_count}" ); // Both docs_url and redoc_url should be present diff --git a/crates/vespera_macro/src/router_codegen/generator/tests_paths.rs b/crates/vespera_macro/src/router_codegen/generator/tests_paths.rs index b6f614e5..5c2dca40 100644 --- a/crates/vespera_macro/src/router_codegen/generator/tests_paths.rs +++ b/crates/vespera_macro/src/router_codegen/generator/tests_paths.rs @@ -65,6 +65,18 @@ fn segment_idents(#[case] name: &str, #[case] expected: Option<&str>) { assert_eq!(ident.as_deref(), expected); } +/// The rename suggestion for an invalid segment is itself a valid segment. +#[rstest] +#[case::hyphen("my-file", "my_file")] +#[case::leading_digit("2fa", "_2fa")] +#[case::path_keyword("self", "self_")] +#[case::underscore_only("-", "__")] +fn suggested_idents_are_valid(#[case] name: &str, #[case] expected: &str) { + let suggestion = suggested_ident(name); + assert_eq!(suggestion, expected); + assert!(segment_ident(&suggestion).is_some(), "{suggestion}"); +} + /// Keyword module / handler names become raw identifiers; gated routes are /// mounted under their `#[cfg]` after the ungated chain. #[test] diff --git a/crates/vespera_macro/src/router_codegen/security_dsl.rs b/crates/vespera_macro/src/router_codegen/security_dsl.rs index d3fdff9b..091fc8d8 100644 --- a/crates/vespera_macro/src/router_codegen/security_dsl.rs +++ b/crates/vespera_macro/src/router_codegen/security_dsl.rs @@ -134,6 +134,14 @@ fn parse_flow(input: ParseStream, kind: &syn::Ident) -> syn::Result { syn::braced!(content in input); let mut urls: BTreeMap = BTreeMap::new(); let mut scopes = None; + // The URLs the OpenAPI Flow Object of `kind` requires — and the only + // ones besides `refresh_url` it takes (`implicit` has no `token_url`, + // `password` / `client_credentials` no `authorization_url`). + let required: &[&str] = match kind.to_string().as_str() { + "implicit" => &["authorization_url"], + "authorization_code" => &["authorization_url", "token_url"], + _ => &["token_url"], + }; while !content.is_empty() { let field: syn::Ident = content.parse()?; let field_str = field.to_string(); @@ -145,6 +153,15 @@ fn parse_flow(input: ParseStream, kind: &syn::Ident) -> syn::Result { } content.parse::()?; match field_str.as_str() { + "authorization_url" | "token_url" if !required.contains(&field_str.as_str()) => { + return Err(syn::Error::new( + field.span(), + format!( + "OAuth2 `{kind}` flow does not take `{field_str}`; it uses {}", + quoted_list(required) + ), + )); + } "authorization_url" | "token_url" | "refresh_url" => { urls.insert(field_str, content.parse::()?.value()); } @@ -164,11 +181,6 @@ fn parse_flow(input: ParseStream, kind: &syn::Ident) -> syn::Result { } content.parse::()?; } - let required: &[&str] = match kind.to_string().as_str() { - "implicit" => &["authorization_url"], - "authorization_code" => &["authorization_url", "token_url"], - _ => &["token_url"], - }; if let Some(missing) = required.iter().find(|field| !urls.contains_key(**field)) { return Err(syn::Error::new( kind.span(), diff --git a/crates/vespera_macro/src/router_codegen/security_dsl_tests.rs b/crates/vespera_macro/src/router_codegen/security_dsl_tests.rs index 04708cca..133ff0b6 100644 --- a/crates/vespera_macro/src/router_codegen/security_dsl_tests.rs +++ b/crates/vespera_macro/src/router_codegen/security_dsl_tests.rs @@ -46,7 +46,15 @@ fn every_flow_kind_parses_with_its_urls_and_scopes() { )] #[case::implicit_missing_authorization_url( "implicit_missing_authorization_url", - quote::quote!(= { implicit = { token_url = "https://t" } }) + quote::quote!(= { implicit = { scopes = {} } }) +)] +#[case::implicit_with_token_url( + "implicit_with_token_url", + quote::quote!(= { implicit = { authorization_url = "https://a", token_url = "https://t" } }) +)] +#[case::password_with_authorization_url( + "password_with_authorization_url", + quote::quote!(= { password = { authorization_url = "https://a", token_url = "https://t" } }) )] #[case::authorization_code_missing_token_url( "authorization_code_missing_token_url", diff --git a/crates/vespera_macro/src/router_codegen/snapshots/vespera_macro__router_codegen__security_dsl__tests__security_dsl_flows_error_implicit_with_token_url.snap b/crates/vespera_macro/src/router_codegen/snapshots/vespera_macro__router_codegen__security_dsl__tests__security_dsl_flows_error_implicit_with_token_url.snap new file mode 100644 index 00000000..1334a7d1 --- /dev/null +++ b/crates/vespera_macro/src/router_codegen/snapshots/vespera_macro__router_codegen__security_dsl__tests__security_dsl_flows_error_implicit_with_token_url.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/router_codegen/security_dsl_tests.rs +expression: error.to_string() +--- +OAuth2 `implicit` flow does not take `token_url`; it uses `authorization_url` diff --git a/crates/vespera_macro/src/router_codegen/snapshots/vespera_macro__router_codegen__security_dsl__tests__security_dsl_flows_error_password_with_authorization_url.snap b/crates/vespera_macro/src/router_codegen/snapshots/vespera_macro__router_codegen__security_dsl__tests__security_dsl_flows_error_password_with_authorization_url.snap new file mode 100644 index 00000000..ae152841 --- /dev/null +++ b/crates/vespera_macro/src/router_codegen/snapshots/vespera_macro__router_codegen__security_dsl__tests__security_dsl_flows_error_password_with_authorization_url.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/router_codegen/security_dsl_tests.rs +expression: error.to_string() +--- +OAuth2 `password` flow does not take `authorization_url`; it uses `token_url` diff --git a/crates/vespera_macro/src/schema_impl/default_fns.rs b/crates/vespera_macro/src/schema_impl/default_fns.rs index d285a006..a3814b7c 100644 --- a/crates/vespera_macro/src/schema_impl/default_fns.rs +++ b/crates/vespera_macro/src/schema_impl/default_fns.rs @@ -35,7 +35,8 @@ pub(super) fn resolve_default_fn( module_scope::module_items(module)? } else { let file = file_cache::get_parsed_file(file_path)?; - let mut chain = module_scope::item_module_chain(&file.items, struct_name, line); + let mut chain = + module_scope::item_module_chain_in_file(file_path, &file.items, struct_name, line); for segment in module { match segment.as_str() { "self" => {} diff --git a/crates/vespera_macro/src/schema_impl/tests.rs b/crates/vespera_macro/src/schema_impl/tests.rs index 3b06a00e..9a9b2182 100644 --- a/crates/vespera_macro/src/schema_impl/tests.rs +++ b/crates/vespera_macro/src/schema_impl/tests.rs @@ -895,20 +895,6 @@ fn process_derive_schema_returns_all_unresolved_serde_default_errors() { ); } -#[test] -fn extract_field_defaults_skips_qualified_default_functions() { - let input: syn::DeriveInput = syn::parse_quote! { - struct Config { - #[serde(default = "crate::defaults::name")] - name: String, - } - }; - - let defaults = extract_field_defaults_from_path(&input, Path::new("unused.rs")); - - assert!(defaults.is_empty()); -} - #[test] fn serde_default_validation_accepts_schema_function_and_type_defaults() { let explicit_schema: syn::DeriveInput = syn::parse_quote! { diff --git a/crates/vespera_macro/src/schema_macro/codegen.rs b/crates/vespera_macro/src/schema_macro/codegen.rs index 07ccb455..f0ac23ac 100644 --- a/crates/vespera_macro/src/schema_macro/codegen.rs +++ b/crates/vespera_macro/src/schema_macro/codegen.rs @@ -12,7 +12,7 @@ //! (required-by-nullability, doc descriptions, `flatten`/`transparent` //! composition, field constraints, and `$ref` references). -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use proc_macro2::TokenStream; use quote::quote; @@ -39,8 +39,19 @@ pub fn generate_filtered_schema( omit_set: &HashSet, pick_set: &HashSet, schema_storage: &HashMap, + field_defaults: &BTreeMap, ) -> TokenStream { - let schema = build_filtered_schema(struct_item, omit_set, pick_set, schema_storage); + let mut schema = build_filtered_schema(struct_item, omit_set, pick_set, schema_storage); + // The same `default` post-processing the component schema gets (the + // derive's pre-extracted `field_defaults`, `#[schema(default)]`, type + // defaults); filtered-out fields have no property to receive one. + crate::openapi_generator::process_default_functions_for( + struct_item, + None, + &mut schema, + field_defaults, + crate::parser::schema::SerdeSide::Serialize, + ); // Serialize at compile time; the runtime value is reconstructed from // this spec so it cannot diverge from the OpenAPI component schema. let json = serde_json::to_string(&schema).expect("Schema serialization is infallible"); @@ -179,7 +190,7 @@ fn filter_struct_fields( #[cfg(test)] mod tests { - use std::collections::{HashMap, HashSet}; + use std::collections::{BTreeMap, HashMap, HashSet}; use crate::metadata::StructMetadata; @@ -321,9 +332,14 @@ mod tests { #[test] fn generate_emits_from_compiled_json_call() { let item = parse(r"pub struct S { pub a: String }"); - let output = - generate_filtered_schema(&item, &HashSet::new(), &HashSet::new(), &empty_storage()) - .to_string(); + let output = generate_filtered_schema( + &item, + &HashSet::new(), + &HashSet::new(), + &empty_storage(), + &BTreeMap::new(), + ) + .to_string(); assert!( output.contains("from_compiled_json"), "schema! must emit a from_compiled_json reconstruction, got: {output}" @@ -333,6 +349,52 @@ mod tests { assert!(output.contains("required"), "spec must carry required"); } + /// `schema!` carries the same `default`s as the component schema: the + /// derive's pre-extracted fn default, a type default, an explicit + /// `#[schema(default)]` — and none for an omitted field. + #[test] + fn generate_applies_field_defaults() { + let item = parse( + r#"pub struct S { + #[serde(default = "limit")] pub limit: u32, + #[serde(default)] pub bio: String, + #[schema(default = "7")] pub page: u32, + #[serde(default = "gone")] pub gone: u32, + }"#, + ); + let defaults = BTreeMap::from([ + ("limit".to_string(), serde_json::json!(10)), + ("gone".to_string(), serde_json::json!(1)), + ]); + let omit = HashSet::from(["gone".to_string()]); + let output = + generate_filtered_schema(&item, &omit, &HashSet::new(), &empty_storage(), &defaults); + let syn::Expr::Call(call) = syn::parse2::(output).unwrap() else { + panic!("a call"); + }; + let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(json), + .. + }) = &call.args[0] + else { + panic!("a string literal"); + }; + let spec: serde_json::Value = serde_json::from_str(&json.value()).unwrap(); + let defaults: Vec<_> = ["limit", "bio", "page", "gone"] + .iter() + .map(|field| spec["properties"][field]["default"].clone()) + .collect(); + assert_eq!( + defaults, + [ + serde_json::json!(10), + serde_json::json!(""), + serde_json::json!(7), + serde_json::Value::Null + ] + ); + } + #[test] fn filtering_every_field_normalizes_empty_properties_and_required() { let item = parse("pub struct S { pub only: String }"); diff --git a/crates/vespera_macro/src/schema_macro/defaults.rs b/crates/vespera_macro/src/schema_macro/defaults.rs index 56f3d845..3286f1e8 100644 --- a/crates/vespera_macro/src/schema_macro/defaults.rs +++ b/crates/vespera_macro/src/schema_macro/defaults.rs @@ -105,9 +105,14 @@ pub(super) fn generate_sea_orm_default_attrs( return (quote! {}, quote! {}); }; - let schema_default_attr = schema_default_str - .map(|value| quote! { #[schema(default = #value)] }) - .unwrap_or_default(); + // A machine-dependent value (`Local`) advertises no OpenAPI + // default, but the bare `#[schema(default)]` marker still tells + // the DTO's `#[derive(Schema)]` the serde default is deliberate + // (its generated fn is not in any source file to resolve). + let schema_default_attr = schema_default_str.map_or_else( + || quote! { #[schema(default)] }, + |value| quote! { #[schema(default = #value)] }, + ); if has_existing_serde_default { return (quote! {}, schema_default_attr); diff --git a/crates/vespera_macro/src/schema_macro/defaults/tests.rs b/crates/vespera_macro/src/schema_macro/defaults/tests.rs index a655ddb9..ca967559 100644 --- a/crates/vespera_macro/src/schema_macro/defaults/tests.rs +++ b/crates/vespera_macro/src/schema_macro/defaults/tests.rs @@ -782,7 +782,8 @@ fn now_default_matches_field_type(#[case] ty: &str, #[case] expr: &str, #[case] return; } if schema == "-" { - assert!(schema_attr.is_empty(), "{schema_attr}"); + // Only the bare marker the DTO's `#[derive(Schema)]` accepts. + assert_eq!(schema_attr.to_string(), "# [schema (default)]"); } else { assert!( schema_attr.to_string().contains(&format!("\"{schema}\"")), @@ -793,6 +794,26 @@ fn now_default_matches_field_type(#[case] ty: &str, #[case] expr: &str, #[case] assert!(body.contains(expr), "{body}"); } +/// A source field that already carries `#[serde(default)]` keeps it (no +/// generated fn) but still gets the schema marker, so a `Local` `NOW()` +/// field compiles under the DTO's `#[derive(Schema)]`. +#[rstest::rstest] +#[case::local("DateTimeLocal", "# [schema (default)]")] +#[case::utc("DateTimeUtc", "# [schema (default = \"1970-01-01T00:00:00Z\")]")] +fn now_default_with_existing_serde_default(#[case] ty: &str, #[case] expected: &str) { + let attrs: Vec = vec![ + syn::parse_quote!(#[sea_orm(default_value = "NOW()")]), + syn::parse_quote!(#[serde(default)]), + ]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str(ty).unwrap(); + let mut fns = Vec::new(); + let (serde_attr, schema_attr) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "at", &ty, &ty, false, &mut fns); + assert!(fns.is_empty() && serde_attr.is_empty()); + assert_eq!(schema_attr.to_string(), expected); +} + /// `primary_key, auto_increment = false` is a client-supplied key: no /// implicit default. #[rstest::rstest] diff --git a/crates/vespera_macro/src/schema_macro/file_lookup/lookup.rs b/crates/vespera_macro/src/schema_macro/file_lookup/lookup.rs index d447087e..72fc80d9 100644 --- a/crates/vespera_macro/src/schema_macro/file_lookup/lookup.rs +++ b/crates/vespera_macro/src/schema_macro/file_lookup/lookup.rs @@ -399,11 +399,14 @@ pub fn storage_module_path(metadata: &StructMetadata) -> Option> { .next() .and_then(|line| line.parse().ok()) .unwrap_or_default(); - module.extend(crate::schema_macro::module_scope::item_module_chain( - &parsed.items, - ident.trim_start_matches("r#"), - line, - )); + module.extend( + crate::schema_macro::module_scope::item_module_chain_in_file( + file, + &parsed.items, + ident.trim_start_matches("r#"), + line, + ), + ); } Some(module) } diff --git a/crates/vespera_macro/src/schema_macro/mod.rs b/crates/vespera_macro/src/schema_macro/mod.rs index eec47a2f..94349ecd 100644 --- a/crates/vespera_macro/src/schema_macro/mod.rs +++ b/crates/vespera_macro/src/schema_macro/mod.rs @@ -89,8 +89,13 @@ pub fn generate_schema_code( let pick_set: HashSet = input.pick.iter().flatten().cloned().collect(); // Generate schema with filtering - let schema_tokens = - generate_filtered_schema(&parsed_struct, &omit_set, &pick_set, schema_storage); + let schema_tokens = generate_filtered_schema( + &parsed_struct, + &omit_set, + &pick_set, + schema_storage, + &struct_def.field_defaults, + ); Ok(schema_tokens) } diff --git a/crates/vespera_macro/src/schema_macro/module_scope.rs b/crates/vespera_macro/src/schema_macro/module_scope.rs index 6edd6a2c..ff3b35fb 100644 --- a/crates/vespera_macro/src/schema_macro/module_scope.rs +++ b/crates/vespera_macro/src/schema_macro/module_scope.rs @@ -169,6 +169,54 @@ pub fn struct_in_items(items: &[syn::Item], name: &str) -> Option { /// file's top level) when it is not found — a `schema_type!` DTO is not /// written in the file at all. pub fn item_module_chain(items: &[syn::Item], name: &str, line: usize) -> Vec { + pick_chain(chain_candidates(items, name), line) +} + +/// [`item_module_chain`] for `file`, whose cached parse is `items`. A repeated +/// name is told apart by line, so the file is then re-parsed with +/// proc-macro2's fallback forced: inside the compiler every token of re-parsed +/// text carries the call site's span, so no candidate's line would match. +pub fn item_module_chain_in_file( + file: &Path, + items: &[syn::Item], + name: &str, + line: usize, +) -> Vec { + /// Restores proc-macro2's normal mode even if parsing panics. + struct Unforce; + impl Drop for Unforce { + fn drop(&mut self) { + proc_macro2::fallback::unforce(); + } + } + let found = chain_candidates(items, name); + if found.len() <= 1 { + return pick_chain(found, line); + } + let Some(content) = file_cache::get_file_content(file) else { + return Vec::new(); + }; + proc_macro2::fallback::force(); + let _unforce = Unforce; + syn::parse_file(&content) + .map(|parsed| item_module_chain(&parsed.items, name, line)) + .unwrap_or_default() +} + +/// The only candidate's chain, or (several) the one whose ident is at `line`. +fn pick_chain(mut found: Vec<(usize, Vec)>, line: usize) -> Vec { + if found.len() > 1 { + found.retain(|(found_line, _)| *found_line == line); + } + match found.as_mut_slice() { + [(_, chain)] => std::mem::take(chain), + _ => Vec::new(), + } +} + +/// Every struct / enum `name` in `items` (inline modules included): its +/// ident's line and inline `mod` chain. +fn chain_candidates(items: &[syn::Item], name: &str) -> Vec<(usize, Vec)> { fn walk( items: &[syn::Item], name: &str, @@ -196,13 +244,7 @@ pub fn item_module_chain(items: &[syn::Item], name: &str, line: usize) -> Vec 1 { - found.retain(|(found_line, _)| *found_line == line); - } - match found.as_slice() { - [(_, chain)] => chain.clone(), - _ => Vec::new(), - } + found } /// The struct `name` defined anywhere in `items` (inline modules included) diff --git a/crates/vespera_macro/src/schema_macro/module_scope/tests.rs b/crates/vespera_macro/src/schema_macro/module_scope/tests.rs index 4cb59f23..d277e231 100644 --- a/crates/vespera_macro/src/schema_macro/module_scope/tests.rs +++ b/crates/vespera_macro/src/schema_macro/module_scope/tests.rs @@ -379,3 +379,29 @@ fn item_module_chain_walks_inline_modules( .unwrap(); assert_eq!(item_module_chain(&file.items, name, line), expected); } + +/// `item_module_chain_in_file` re-parses the file for real line positions +/// when a name repeats: the cached `items` here stand for a compiler parse +/// whose tokens all sit on one line (the call site), which matches no line. +#[rstest] +#[case::repeated_name_reparsed("Dup", 5, true, &["b2"])] +#[case::unique_name_uses_items("Kind", 0, true, &["a"])] +#[case::unreadable_file("Dup", 5, false, &[])] +fn item_module_chain_in_file_reparses_repeated_names( + #[case] name: &str, + #[case] line: usize, + #[case] write: bool, + #[case] expected: &[&str], +) { + let source = "mod a {\n pub enum Kind { A }\n}\n\nmod b2 { pub struct Dup; }\nmod b3 { pub struct Dup; }\n"; + let dir = TempDir::new().unwrap(); + let file = dir.path().join("lib.rs"); + if write { + std::fs::write(&file, source).unwrap(); + } + let one_line = syn::parse_file(&source.replace('\n', " ")).unwrap(); + assert_eq!( + item_module_chain_in_file(&file, &one_line.items, name, line), + expected + ); +} diff --git a/crates/vespera_macro/src/schema_macro/transformation/schema_type_option_tests.rs b/crates/vespera_macro/src/schema_macro/transformation/schema_type_option_tests.rs index 83477b34..92f2322e 100644 --- a/crates/vespera_macro/src/schema_macro/transformation/schema_type_option_tests.rs +++ b/crates/vespera_macro/src/schema_macro/transformation/schema_type_option_tests.rs @@ -15,6 +15,40 @@ fn to_storage(items: Vec) -> HashMap { items.into_iter().map(|s| (s.name.clone(), s)).collect() } +/// The derive names (last path segment) on the generated struct `name`. +fn derives_of(tokens: &proc_macro2::TokenStream, name: &str) -> Vec { + let file: syn::File = syn::parse2(tokens.clone()).unwrap(); + let item = file + .items + .iter() + .find_map(|item| match item { + syn::Item::Struct(item) if item.ident == name => Some(item), + _ => None, + }) + .unwrap(); + let mut derives = Vec::new(); + for attr in item + .attrs + .iter() + .filter(|attr| attr.path().is_ident("derive")) + { + attr.parse_nested_meta(|meta| { + derives.push(meta.path.segments.last().unwrap().ident.to_string()); + Ok(()) + }) + .unwrap(); + } + derives +} + +/// Whether the generated items hold an `impl From<..> for ..`. +fn has_from_impl(tokens: &proc_macro2::TokenStream) -> bool { + let file: syn::File = syn::parse2(tokens.clone()).unwrap(); + file.items.iter().any(|item| { + matches!(item, syn::Item::Impl(item) if item.trait_.as_ref().is_some_and(|(path, _)| path.segments.last().unwrap().ident == "From")) + }) +} + // Tests for field rename processing #[test] @@ -33,7 +67,8 @@ fn test_generate_schema_type_code_with_rename() { let output = tokens.to_string(); assert!(output.contains("user_id")); // The From impl should map user_id from source.id - assert!(output.contains("From")); + assert!(has_from_impl(&tokens)); + assert!(derives_of(&tokens, "UserDTO").iter().any(|d| d == "Clone")); } #[test] @@ -176,9 +211,9 @@ fn test_generate_schema_type_code_with_clone_false() { assert!(result.is_ok()); let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Should NOT contain Clone derive - assert!(!output.contains("Clone ,")); + let derives = derives_of(&tokens, "UserNonClone"); + assert!(!derives.iter().any(|d| d == "Clone"), "{derives:?}"); + assert!(derives.iter().any(|d| d == "Serialize"), "{derives:?}"); } // Test for SeaORM model detection @@ -468,8 +503,8 @@ fn test_generate_schema_type_code_seaorm_model_with_relation_generates_from_mode let output = tokens.to_string(); // Should have relation field assert!(output.contains("user")); - // Should NOT have regular From impl (because of relation) - // The From impl is only generated when there are no relation fields + // No regular From impl: relation fields need the async from_model. + assert!(!has_from_impl(&tokens), "{output}"); } // Test for from_model generation with relations @@ -497,7 +532,6 @@ fn test_generate_schema_type_code_from_model_generation() { let output = tokens.to_string(); // Has relation field assert!(output.contains("user")); - // Regular impl From should NOT be present (because has relations) - // Check that we don't have "impl From < Model > for MemoSchema" - // (Relations disable the automatic From impl) + // Relations disable the automatic From impl. + assert!(!has_from_impl(&tokens), "{output}"); } diff --git a/crates/vespera_macro/src/vespera_impl.rs b/crates/vespera_macro/src/vespera_impl.rs index 4ce89f5f..3e19c39a 100644 --- a/crates/vespera_macro/src/vespera_impl.rs +++ b/crates/vespera_macro/src/vespera_impl.rs @@ -6,6 +6,7 @@ mod cache; mod cron_discovery; +mod export_names; mod invocation; mod merge_conflicts; mod merge_lookup; diff --git a/crates/vespera_macro/src/vespera_impl/cache/tests.rs b/crates/vespera_macro/src/vespera_impl/cache/tests.rs index 2d6ce988..2934f184 100644 --- a/crates/vespera_macro/src/vespera_impl/cache/tests.rs +++ b/crates/vespera_macro/src/vespera_impl/cache/tests.rs @@ -452,22 +452,23 @@ fn security_requirement_scopes_affect_config_hash( #[test] fn present_merge_sidecar_content_contributes_to_config_hash() { let temp = tempfile::TempDir::new().unwrap(); - let vespera_dir = temp.path().join("target/vespera"); - std::fs::create_dir_all(&vespera_dir).unwrap(); - std::fs::write( - crate::vespera_impl::merge_lookup::child_spec_path(&vespera_dir, "child", "Child"), - "child-v1", - ) - .unwrap(); + let vespera_dir = find_target_dir(temp.path()).join("vespera"); + let sidecar = + crate::vespera_impl::merge_lookup::child_spec_path(&vespera_dir, "child", "Child"); let _env_guard = crate::env_access::test_env::set_var("CARGO_MANIFEST_DIR", temp.path()); let processed = ProcessedVesperaInput { merge: vec![syn::parse_quote!(child::Child)], ..base_processed() }; - let mut cache = MergeSpecCache::new(); + let hash_with = |content: &str| { + std::fs::write(&sidecar, content).unwrap(); + compute_config_hash_with_merge_cache(&processed, &mut MergeSpecCache::new()) + }; - let with_child = compute_config_hash_with_merge_cache(&processed, &mut cache); - assert_ne!(with_child, compute_config_hash(&base_processed())); + // Same merge list, only the sidecar's bytes differ. + let v1 = hash_with("child-v1"); + assert_eq!(v1, hash_with("child-v1")); + assert_ne!(v1, hash_with("child-v2")); } #[test] diff --git a/crates/vespera_macro/src/vespera_impl/cron_discovery.rs b/crates/vespera_macro/src/vespera_impl/cron_discovery.rs index 94ff938e..04b4424b 100644 --- a/crates/vespera_macro/src/vespera_impl/cron_discovery.rs +++ b/crates/vespera_macro/src/vespera_impl/cron_discovery.rs @@ -307,6 +307,7 @@ impl ModuleTree { } fn visit(&mut self, items: &[syn::Item], scope: &Scope<'_>, collect_jobs: bool) { + let foreign_cron = imports_foreign_cron(items); for item in items { match item { syn::Item::Mod(module) => { @@ -338,7 +339,7 @@ impl ModuleTree { syn::Item::Fn(function) if collect_jobs && function.attrs.iter().any(|a| is_cron_path(a.path())) => { - self.add_cron_fn(function, scope); + self.add_cron_fn(function, scope, foreign_cron); } _ => {} } @@ -364,7 +365,7 @@ impl ModuleTree { /// Index a function carrying a cron attribute and, when it is /// schedulable and not `#[cfg]`-gated, record it as a scanned job. - fn add_cron_fn(&mut self, function: &syn::ItemFn, scope: &Scope<'_>) { + fn add_cron_fn(&mut self, function: &syn::ItemFn, scope: &Scope<'_>, foreign_cron: bool) { let function_name = function.sig.ident.unraw().to_string(); self.cron_fns .entry((scope.file.to_owned(), function_name.clone())) @@ -377,7 +378,7 @@ impl ModuleTree { )) .or_insert_with(|| scope.module_path.to_owned()); if !scope.gated - && let Some(args) = cron_args(function) + && let Some(args) = cron_args(function, foreign_cron) { self.jobs.push(CronMetadata { expression: args.expression.value(), @@ -392,15 +393,18 @@ impl ModuleTree { /// The arguments of a schedulable `#[cron("…", tz = "…", overlap = "…")]` /// function, or `None` when the function is not one (the attribute macro -/// reports malformed ones itself). -fn cron_args(function: &syn::ItemFn) -> Option { +/// reports malformed ones itself). With `foreign_cron` (the module imports +/// another crate's `cron`) a bare `#[cron]` is not vespera's. +fn cron_args(function: &syn::ItemFn, foreign_cron: bool) -> Option { if validate_cron_fn(function).is_err() || is_cfg_gated(&function.attrs) { return None; } function .attrs .iter() - .find(|attr| is_cron_path(attr.path())) + .find(|attr| { + is_cron_path(attr.path()) && !(foreign_cron && attr.path().segments.len() == 1) + }) .and_then(|attr| attr.parse_args::().ok()) } @@ -417,6 +421,33 @@ fn is_cron_path(path: &syn::Path) -> bool { ) } +/// Whether `items` import a `cron` from another crate (`use other::cron;`, +/// `use other::{cron};`, `use other::job as cron;`) — then a bare `#[cron]` +/// there is that crate's attribute. Imports rooted at `vespera`, `crate`, +/// `self` or `super` and globs keep it vespera's. +fn imports_foreign_cron(items: &[syn::Item]) -> bool { + fn imports(tree: &syn::UseTree, root: Option<&syn::Ident>) -> bool { + let foreign = || { + root.is_some_and(|root| { + !matches!( + root.to_string().as_str(), + "vespera" | "crate" | "self" | "super" + ) + }) + }; + match tree { + syn::UseTree::Path(path) => imports(&path.tree, root.or(Some(&path.ident))), + syn::UseTree::Name(name) => name.ident == "cron" && foreign(), + syn::UseTree::Rename(rename) => rename.rename == "cron" && foreign(), + syn::UseTree::Group(group) => group.items.iter().any(|tree| imports(tree, root)), + syn::UseTree::Glob(_) => false, + } + } + items + .iter() + .any(|item| matches!(item, syn::Item::Use(item) if imports(&item.tree, None))) +} + fn is_cfg_gated(attrs: &[syn::Attribute]) -> bool { attrs.iter().any(|attr| attr.path().is_ident("cfg")) } @@ -472,510 +503,4 @@ fn cron_module_path(relative: &str) -> String { } #[cfg(test)] -mod tests { - use std::fs; - - use rstest::rstest; - use tempfile::TempDir; - - use crate::metadata::CronOptions; - - use super::*; - - fn discover( - storage: &[StoredCronInfo], - src_dir: Option<&Path>, - call_site: Option<&Path>, - ) -> Vec { - discover_cron_jobs(storage, src_dir, call_site).unwrap() - } - - /// `vespera!` in an example / integration test (outside `src/`): the - /// call-site file is the crate root, its `mod`s resolve next to it, and - /// jobs there — stored or not — get real module paths. - #[test] - fn call_site_outside_src_is_the_crate_root() { - let krate = crate_with(&[("lib.rs", "")]); - let examples = krate.path().join("examples"); - fs::create_dir_all(examples.join("demo")).unwrap(); - let root = examples.join("demo").join("main.rs"); - fs::write( - &root, - r#"mod jobs; #[cron("1 * * * * *")] pub async fn root_job() {}"#, - ) - .unwrap(); - let jobs_file = examples.join("demo").join("jobs.rs"); - fs::write( - &jobs_file, - r#"#[cron("2 * * * * *")] pub async fn tick() {} #[cron("3 * * * * *")] pub async fn late() {}"#, - ) - .unwrap(); - crate::schema_macro::file_cache::bump_epoch(); - let src = krate.path().join("src"); - let storage = [stored("tick", Some(jobs_file.display().to_string()))]; - let jobs = discover(&storage, Some(&src), Some(&root)); - assert_eq!( - summary(&jobs), - rows(&[ - ("", "root_job", "1 * * * * *"), - ("jobs", "late", "3 * * * * *"), - ("jobs", "tick", "0 0 0 * * *"), - ]) - ); - // Without a tree to consult the stored file is still placed relative - // to the call site (here: `examples/demo` is not under `src/`). - let placement = Placement { - src_dir: Some(&src), - tree: None, - call_site: Some(canonical_display(&root)), - }; - let placed = stored_jobs( - &[ - stored("tick", Some(jobs_file.display().to_string())), - stored("root_job", Some(root.display().to_string())), - ], - &placement, - &mut HashMap::new(), - ) - .unwrap(); - assert_eq!( - summary(&placed), - rows(&[ - ("", "root_job", "0 0 0 * * *"), - ("jobs", "tick", "0 0 0 * * *") - ]) - ); - } - - /// A stored job whose file is neither under `src/` nor next to the call - /// site has no module path: a compile error, not an absolute-path module. - #[test] - fn stored_job_outside_the_crate_is_an_error() { - let krate = crate_with(&[("lib.rs", "")]); - let elsewhere = TempDir::new().unwrap(); - let stray = elsewhere.path().join("stray.rs"); - fs::write(&stray, r#"#[cron("1 * * * * *")] pub async fn stray() {}"#).unwrap(); - let src = krate.path().join("src"); - let storage = [stored("stray", Some(stray.display().to_string()))]; - for call_site in [Some(src.join("lib.rs")), None] { - let error = discover_cron_jobs(&storage, Some(&src), call_site.as_deref()).unwrap_err(); - assert!( - error.contains("cannot place `#[cron]` job `stray`"), - "unexpected error: {error}" - ); - } - // A sibling directory sharing the `src` prefix is not `src/`. - let src2 = krate.path().join("src2"); - fs::create_dir_all(&src2).unwrap(); - let sibling = src2.join("job.rs"); - fs::write(&sibling, "").unwrap(); - let placement = Placement { - src_dir: Some(&src), - tree: None, - call_site: Some(canonical_display(&src.join("lib.rs"))), - }; - assert!( - stored_jobs( - &[stored("job", Some(sibling.display().to_string()))], - &placement, - &mut HashMap::new() - ) - .is_err() - ); - } - - /// Write `files` (relative to `src/`) into a fresh temp crate and return it. - fn crate_with(files: &[(&str, &str)]) -> TempDir { - let dir = TempDir::new().expect("temp dir"); - for (relative, content) in files { - let path = dir.path().join("src").join(relative); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(path, content).unwrap(); - } - dir - } - - fn stored(fn_name: &str, file_path: Option) -> StoredCronInfo { - StoredCronInfo { - fn_name: fn_name.to_owned(), - expression: "0 0 0 * * *".to_owned(), - file_path, - inline_module: None, - options: CronOptions::default(), - } - } - - fn stored_in(fn_name: &str, file_path: &Path, inline: &str, expr: &str) -> StoredCronInfo { - StoredCronInfo { - expression: expr.to_owned(), - inline_module: Some(inline.to_owned()), - ..stored(fn_name, Some(file_path.display().to_string())) - } - } - - /// Same-named jobs in different inline modules of one file: each stored - /// entry is placed in its own module, and a sibling the storage does not - /// know yet is still scanned. `#[path]` files keep the recorded inline - /// module on the derived path. - #[test] - fn same_named_jobs_in_inline_modules_do_not_clash() { - let krate = crate_with(&[ - ( - "lib.rs", - r#" - pub mod a { #[cron("1 * * * * *")] pub async fn tick() {} } - pub mod b { #[cron("2 * * * * *")] pub async fn tick() {} } - pub mod c { pub mod d { #[cron("3 * * * * *")] pub async fn tick() {} } } - #[path = "x.rs"] mod pathed; - "#, - ), - ( - "x.rs", - r#"mod e { #[cron("4 * * * * *")] pub async fn tick() {} }"#, - ), - ]); - let src = krate.path().join("src"); - let lib = src.join("lib.rs"); - let storage = [ - stored_in("tick", &lib, "a", "1 * * * * *"), - stored_in("tick", &lib, "b", "2 * * * * *"), - stored_in("tick", &src.join("x.rs"), "e", "4 * * * * *"), - ]; - let jobs = discover(&storage, Some(&src), Some(&lib)); - assert_eq!( - summary(&jobs), - rows(&[ - ("a", "tick", "1 * * * * *"), - ("b", "tick", "2 * * * * *"), - ("c::d", "tick", "3 * * * * *"), - ("x::e", "tick", "4 * * * * *"), - ]) - ); - } - - fn summary(jobs: &[CronMetadata]) -> Vec<(String, String, String)> { - let mut rows: Vec<_> = jobs - .iter() - .map(|job| { - ( - job.module_path.clone(), - job.function_name.clone(), - job.expression.clone(), - ) - }) - .collect(); - rows.sort(); - rows - } - - #[rstest] - #[case::slashes_and_hyphens("admin-tools/daily-job.rs", "admin_tools::daily_job")] - #[case::module_file("admin-tools/mod.rs", "admin_tools")] - #[case::library_root("lib.rs", "")] - #[case::binary_root("main.rs", "")] - #[case::nested_lib_file("tools/lib.rs", "tools::lib")] - fn cron_module_path_normalizes_rust_module_syntax(#[case] input: &str, #[case] expected: &str) { - assert_eq!(cron_module_path(input), expected); - } - - #[test] - fn storage_entries_without_src_dir_keep_their_derived_paths() { - let jobs = discover( - &[ - stored("no_file", None), - stored("missing", Some("does/not/exist.rs".to_owned())), - ], - None, - None, - ); - assert_eq!( - summary(&jobs), - vec![ - ( - String::new(), - "no_file".to_owned(), - "0 0 0 * * *".to_owned() - ), - ( - "does::not::exist".to_owned(), - "missing".to_owned(), - "0 0 0 * * *".to_owned() - ), - ] - ); - } - - /// Jobs in modules rustc expands after `vespera!` are recovered from the - /// module tree; storage-known, gated, `#[path]`, private, non-literal and - /// unreachable ones are not. - #[test] - fn scan_recovers_unexpanded_jobs_from_the_module_tree() { - let krate = crate_with(&[ - ( - "lib.rs", - r#" - mod early; - pub fn create_app() {} - mod late; - mod plain; - mod leafy; - mod broken; - mod missing; - #[cfg(test)] mod gated; - #[path = "x.rs"] mod pathed; - pub mod inline { - #[vespera::cron("0 0 * * * *")] - pub async fn inline_job() {} - } - "#, - ), - ( - "early.rs", - r#"#[cron("1 * * * * *")] pub async fn early_job() {}"#, - ), - ( - "late/mod.rs", - r#" - mod deeper; - #[cron("2 * * * * *", overlap = "skip", tz = "Asia/Seoul")] pub async fn late_job() {} - #[cron("3 * * * * *", bogus = "x")] pub async fn bad_args() {} - #[cron("3 * * * * *")] fn private_job() {} - #[cfg(feature = "x")] #[cron("4 * * * * *")] pub async fn gated_job() {} - #[cron(EXPR)] pub async fn non_literal() {} - #[other] pub async fn not_cron() {} - #[a::cron("9 * * * * *")] pub async fn foreign_cron() {} - "#, - ), - ( - "late/deeper.rs", - r#"#[vespera::cron("5 * * * * *")] pub async fn deep_job() {}"#, - ), - ("plain.rs", "pub mod inner { pub async fn helper() {} }"), - ("leafy.rs", "pub fn leaf() {}"), - ("broken.rs", "mod {{{ cron("), - ( - "gated.rs", - r#"#[cron("6 * * * * *")] pub async fn gated() {}"#, - ), - ("x.rs", r#"#[cron("7 * * * * *")] pub async fn pathed() {}"#), - ( - "stray.rs", - r#"#[cron("8 * * * * *")] pub async fn stray() {}"#, - ), - ]); - let src = krate.path().join("src"); - let early = src.join("early.rs").display().to_string(); - let jobs = discover(&[stored("early_job", Some(early))], Some(&src), None); - assert_eq!( - summary(&jobs), - vec![ - ( - "early".to_owned(), - "early_job".to_owned(), - "0 0 0 * * *".to_owned() - ), - ( - "inline".to_owned(), - "inline_job".to_owned(), - "0 0 * * * *".to_owned() - ), - ( - "late".to_owned(), - "late_job".to_owned(), - "2 * * * * *".to_owned() - ), - ( - "late::deeper".to_owned(), - "deep_job".to_owned(), - "5 * * * * *".to_owned() - ), - ] - ); - // Scanned jobs keep their `tz` / `overlap` arguments. - let late = jobs - .iter() - .find(|job| job.function_name == "late_job") - .unwrap(); - assert_eq!( - late.options, - CronOptions { - timezone: Some("Asia/Seoul".to_owned()), - skip_overlap: true, - } - ); - } - - /// Without a `cron(` attribute outside the storage-covered files nothing - /// is parsed and nothing is added. - #[rstest] - #[case::no_cron_anywhere(&[("lib.rs", "mod a;"), ("a.rs", "pub fn a() {}")], false)] - #[case::all_in_storage(&[("lib.rs", "mod a;"), ("a.rs", r#"#[cron("1 * * * * *")] pub async fn a() {}"#)], true)] - fn scan_adds_nothing_without_unexpanded_candidates( - #[case] files: &[(&str, &str)], - #[case] store_a: bool, - ) { - let krate = crate_with(files); - let src = krate.path().join("src"); - let storage: Vec<_> = store_a - .then(|| stored("a", Some(src.join("a.rs").display().to_string()))) - .into_iter() - .collect(); - let jobs = discover(&storage, Some(&src), None); - assert_eq!(jobs.len(), storage.len()); - } - - /// Only the module tree holding the `vespera!` call site contributes. - #[rstest] - #[case::library_call_site(Some("lib.rs"), vec!["lib_job"])] - #[case::binary_call_site(Some("main.rs"), vec!["bin_job"])] - #[case::outside_both_trees(Some("orphan.rs"), vec![])] - #[case::binary_target(Some("bin/server.rs"), vec!["server_tasks_job", "server_job"])] - #[case::binary_dir_target(Some("bin/worker/main.rs"), vec!["worker_job"])] - #[case::unknown_call_site_prefers_library(None, vec!["lib_job"])] - fn scan_uses_the_tree_containing_the_call_site( - #[case] call_site: Option<&str>, - #[case] expected: Vec<&str>, - ) { - let krate = crate_with(&[ - ( - "lib.rs", - r#"#[cron("1 * * * * *")] pub async fn lib_job() {}"#, - ), - ( - "main.rs", - r#"#[cron("2 * * * * *")] pub async fn bin_job() {}"#, - ), - ("orphan.rs", ""), - ( - "bin/server.rs", - r#"mod tasks; #[cron("3 * * * * *")] pub async fn server_job() {}"#, - ), - ( - "bin/tasks.rs", - r#"#[cron("4 * * * * *")] pub async fn server_tasks_job() {}"#, - ), - ( - "bin/worker/main.rs", - r#"#[cron("5 * * * * *")] pub async fn worker_job() {}"#, - ), - ("bin/README.md", "not a crate root"), - ]); - let src = krate.path().join("src"); - let call_site = call_site.map(|file| src.join(file)); - let jobs = discover(&[], Some(&src), call_site.as_deref()); - let names: Vec<_> = jobs.iter().map(|job| job.function_name.as_str()).collect(); - assert_eq!(names, expected); - } - - /// A job stored from a binary's root file lives at that crate's root, - /// not at `crate::bin::server`. - #[test] - fn binary_root_jobs_are_placed_at_the_crate_root() { - let krate = crate_with(&[( - "bin/server.rs", - r#"#[cron("1 * * * * *")] pub async fn tick() {}"#, - )]); - let src = krate.path().join("src"); - let server = src.join("bin").join("server.rs"); - let storage = [stored("tick", Some(server.display().to_string()))]; - let jobs = discover(&storage, Some(&src), Some(&server)); - assert_eq!(jobs.len(), 1); - assert_eq!(jobs[0].module_path, ""); - } - - #[test] - fn crate_without_a_root_file_contributes_nothing() { - let krate = crate_with(&[("jobs.rs", r#"#[cron("1 * * * * *")] pub async fn job() {}"#)]); - let src = krate.path().join("src"); - assert!(discover(&[], Some(&src), None).is_empty()); - } - - fn rows(rows: &[(&str, &str, &str)]) -> Vec<(String, String, String)> { - let mut rows: Vec<_> = rows - .iter() - .map(|(module, name, expr)| { - ((*module).to_owned(), (*name).to_owned(), (*expr).to_owned()) - }) - .collect(); - rows.sort(); - rows - } - - /// Stored jobs are placed by the module tree: the crate root is `crate` - /// (not `crate::lib`), inline modules keep their segments (also - /// `#[cfg]`-gated ones rustc expanded), and a job declared after - /// `vespera!` in a file with stored jobs is still scanned. - #[test] - fn stored_jobs_take_their_module_path_from_the_module_tree() { - let krate = crate_with(&[ - ( - "lib.rs", - r#" - #[vespera::cron("1 * * * * *")] pub async fn root_job() {} - pub mod jobs { - #[cron("2 * * * * *")] pub async fn inline_job() {} - pub mod deeper { - #[cron("3 * * * * *")] pub async fn deep_job() {} - } - } - #[path = "x.rs"] mod pathed; - pub fn create_app() {} - #[cron("4 * * * * *")] pub async fn after_job() {} - #[cfg(test)] - mod tests { - #[cron("5 * * * * *")] pub async fn gated_job() {} - } - "#, - ), - ( - "x.rs", - r#"#[cron("6 * * * * *")] pub async fn pathed_job() {}"#, - ), - ]); - let src = krate.path().join("src"); - let lib = src.join("lib.rs").display().to_string(); - let x = src.join("x.rs").display().to_string(); - let storage = [ - stored("root_job", Some(lib.clone())), - stored("inline_job", Some(lib.clone())), - stored("deep_job", Some(lib.clone())), - stored("gated_job", Some(lib)), - stored("pathed_job", Some(x)), - ]; - let stored_expr = "0 0 0 * * *"; - let jobs = discover(&storage, Some(&src), Some(&src.join("lib.rs"))); - assert_eq!( - summary(&jobs), - rows(&[ - ("", "root_job", stored_expr), - ("jobs", "inline_job", stored_expr), - ("jobs::deeper", "deep_job", stored_expr), - ("tests", "gated_job", stored_expr), - // `#[path]` modules are not followed: derived from the file. - ("x", "pathed_job", stored_expr), - ("", "after_job", "4 * * * * *"), - ]) - ); - } - - /// Without a module tree to consult (call site outside both trees), a - /// stored crate-root job still resolves to `crate`. - #[test] - fn stored_root_job_without_a_tree_falls_back_to_the_crate_root() { - let krate = crate_with(&[ - ( - "main.rs", - r#"#[cron("1 * * * * *")] pub async fn bin_job() {}"#, - ), - ("orphan.rs", ""), - ]); - let src = krate.path().join("src"); - let main = src.join("main.rs").display().to_string(); - let jobs = discover( - &[stored("bin_job", Some(main))], - Some(&src), - Some(&src.join("orphan.rs")), - ); - assert_eq!(summary(&jobs), rows(&[("", "bin_job", "0 0 0 * * *")])); - } -} +mod tests; diff --git a/crates/vespera_macro/src/vespera_impl/cron_discovery/tests.rs b/crates/vespera_macro/src/vespera_impl/cron_discovery/tests.rs new file mode 100644 index 00000000..ad4d7c3e --- /dev/null +++ b/crates/vespera_macro/src/vespera_impl/cron_discovery/tests.rs @@ -0,0 +1,527 @@ +use std::fs; + +use rstest::rstest; +use tempfile::TempDir; + +use crate::metadata::CronOptions; + +use super::*; + +fn discover( + storage: &[StoredCronInfo], + src_dir: Option<&Path>, + call_site: Option<&Path>, +) -> Vec { + discover_cron_jobs(storage, src_dir, call_site).unwrap() +} + +/// `vespera!` in an example / integration test (outside `src/`): the +/// call-site file is the crate root, its `mod`s resolve next to it, and +/// jobs there — stored or not — get real module paths. +#[test] +fn call_site_outside_src_is_the_crate_root() { + let krate = crate_with(&[("lib.rs", "")]); + let examples = krate.path().join("examples"); + fs::create_dir_all(examples.join("demo")).unwrap(); + let root = examples.join("demo").join("main.rs"); + fs::write( + &root, + r#"mod jobs; #[cron("1 * * * * *")] pub async fn root_job() {}"#, + ) + .unwrap(); + let jobs_file = examples.join("demo").join("jobs.rs"); + fs::write( + &jobs_file, + r#"#[cron("2 * * * * *")] pub async fn tick() {} #[cron("3 * * * * *")] pub async fn late() {}"#, + ) + .unwrap(); + crate::schema_macro::file_cache::bump_epoch(); + let src = krate.path().join("src"); + let storage = [stored("tick", Some(jobs_file.display().to_string()))]; + let jobs = discover(&storage, Some(&src), Some(&root)); + assert_eq!( + summary(&jobs), + rows(&[ + ("", "root_job", "1 * * * * *"), + ("jobs", "late", "3 * * * * *"), + ("jobs", "tick", "0 0 0 * * *"), + ]) + ); + // Without a tree to consult the stored file is still placed relative + // to the call site (here: `examples/demo` is not under `src/`). + let placement = Placement { + src_dir: Some(&src), + tree: None, + call_site: Some(canonical_display(&root)), + }; + let placed = stored_jobs( + &[ + stored("tick", Some(jobs_file.display().to_string())), + stored("root_job", Some(root.display().to_string())), + ], + &placement, + &mut HashMap::new(), + ) + .unwrap(); + assert_eq!( + summary(&placed), + rows(&[ + ("", "root_job", "0 0 0 * * *"), + ("jobs", "tick", "0 0 0 * * *") + ]) + ); +} + +/// A stored job whose file is neither under `src/` nor next to the call +/// site has no module path: a compile error, not an absolute-path module. +#[test] +fn stored_job_outside_the_crate_is_an_error() { + let krate = crate_with(&[("lib.rs", "")]); + let elsewhere = TempDir::new().unwrap(); + let stray = elsewhere.path().join("stray.rs"); + fs::write(&stray, r#"#[cron("1 * * * * *")] pub async fn stray() {}"#).unwrap(); + let src = krate.path().join("src"); + let storage = [stored("stray", Some(stray.display().to_string()))]; + for call_site in [Some(src.join("lib.rs")), None] { + let error = discover_cron_jobs(&storage, Some(&src), call_site.as_deref()).unwrap_err(); + assert!( + error.contains("cannot place `#[cron]` job `stray`"), + "unexpected error: {error}" + ); + } + // A sibling directory sharing the `src` prefix is not `src/`. + let src2 = krate.path().join("src2"); + fs::create_dir_all(&src2).unwrap(); + let sibling = src2.join("job.rs"); + fs::write(&sibling, "").unwrap(); + let placement = Placement { + src_dir: Some(&src), + tree: None, + call_site: Some(canonical_display(&src.join("lib.rs"))), + }; + assert!( + stored_jobs( + &[stored("job", Some(sibling.display().to_string()))], + &placement, + &mut HashMap::new() + ) + .is_err() + ); +} + +/// Write `files` (relative to `src/`) into a fresh temp crate and return it. +fn crate_with(files: &[(&str, &str)]) -> TempDir { + let dir = TempDir::new().expect("temp dir"); + for (relative, content) in files { + let path = dir.path().join("src").join(relative); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, content).unwrap(); + } + dir +} + +fn stored(fn_name: &str, file_path: Option) -> StoredCronInfo { + StoredCronInfo { + fn_name: fn_name.to_owned(), + expression: "0 0 0 * * *".to_owned(), + file_path, + inline_module: None, + options: CronOptions::default(), + } +} + +fn stored_in(fn_name: &str, file_path: &Path, inline: &str, expr: &str) -> StoredCronInfo { + StoredCronInfo { + expression: expr.to_owned(), + inline_module: Some(inline.to_owned()), + ..stored(fn_name, Some(file_path.display().to_string())) + } +} + +/// Same-named jobs in different inline modules of one file: each stored +/// entry is placed in its own module, and a sibling the storage does not +/// know yet is still scanned. `#[path]` files keep the recorded inline +/// module on the derived path. +#[test] +fn same_named_jobs_in_inline_modules_do_not_clash() { + let krate = crate_with(&[ + ( + "lib.rs", + r#" + pub mod a { #[cron("1 * * * * *")] pub async fn tick() {} } + pub mod b { #[cron("2 * * * * *")] pub async fn tick() {} } + pub mod c { pub mod d { #[cron("3 * * * * *")] pub async fn tick() {} } } + #[path = "x.rs"] mod pathed; + "#, + ), + ( + "x.rs", + r#"mod e { #[cron("4 * * * * *")] pub async fn tick() {} }"#, + ), + ]); + let src = krate.path().join("src"); + let lib = src.join("lib.rs"); + let storage = [ + stored_in("tick", &lib, "a", "1 * * * * *"), + stored_in("tick", &lib, "b", "2 * * * * *"), + stored_in("tick", &src.join("x.rs"), "e", "4 * * * * *"), + ]; + let jobs = discover(&storage, Some(&src), Some(&lib)); + assert_eq!( + summary(&jobs), + rows(&[ + ("a", "tick", "1 * * * * *"), + ("b", "tick", "2 * * * * *"), + ("c::d", "tick", "3 * * * * *"), + ("x::e", "tick", "4 * * * * *"), + ]) + ); +} + +fn summary(jobs: &[CronMetadata]) -> Vec<(String, String, String)> { + let mut rows: Vec<_> = jobs + .iter() + .map(|job| { + ( + job.module_path.clone(), + job.function_name.clone(), + job.expression.clone(), + ) + }) + .collect(); + rows.sort(); + rows +} + +#[rstest] +#[case::slashes_and_hyphens("admin-tools/daily-job.rs", "admin_tools::daily_job")] +#[case::module_file("admin-tools/mod.rs", "admin_tools")] +#[case::library_root("lib.rs", "")] +#[case::binary_root("main.rs", "")] +#[case::nested_lib_file("tools/lib.rs", "tools::lib")] +fn cron_module_path_normalizes_rust_module_syntax(#[case] input: &str, #[case] expected: &str) { + assert_eq!(cron_module_path(input), expected); +} + +#[test] +fn storage_entries_without_src_dir_keep_their_derived_paths() { + let jobs = discover( + &[ + stored("no_file", None), + stored("missing", Some("does/not/exist.rs".to_owned())), + ], + None, + None, + ); + assert_eq!( + summary(&jobs), + vec![ + ( + String::new(), + "no_file".to_owned(), + "0 0 0 * * *".to_owned() + ), + ( + "does::not::exist".to_owned(), + "missing".to_owned(), + "0 0 0 * * *".to_owned() + ), + ] + ); +} + +/// Jobs in modules rustc expands after `vespera!` are recovered from the +/// module tree; storage-known, gated, `#[path]`, private, non-literal and +/// unreachable ones are not. +#[test] +fn scan_recovers_unexpanded_jobs_from_the_module_tree() { + let krate = crate_with(&[ + ( + "lib.rs", + r#" + mod early; + pub fn create_app() {} + mod late; + mod plain; + mod leafy; + mod broken; + mod missing; + #[cfg(test)] mod gated; + #[path = "x.rs"] mod pathed; + pub mod inline { + #[vespera::cron("0 0 * * * *")] + pub async fn inline_job() {} + } + "#, + ), + ( + "early.rs", + r#"#[cron("1 * * * * *")] pub async fn early_job() {}"#, + ), + ( + "late/mod.rs", + r#" + mod deeper; + #[cron("2 * * * * *", overlap = "skip", tz = "Asia/Seoul")] pub async fn late_job() {} + #[cron("3 * * * * *", bogus = "x")] pub async fn bad_args() {} + #[cron("3 * * * * *")] fn private_job() {} + #[cfg(feature = "x")] #[cron("4 * * * * *")] pub async fn gated_job() {} + #[cron(EXPR)] pub async fn non_literal() {} + #[other] pub async fn not_cron() {} + #[a::cron("9 * * * * *")] pub async fn foreign_cron() {} + "#, + ), + ( + "late/deeper.rs", + r#"#[vespera::cron("5 * * * * *")] pub async fn deep_job() {}"#, + ), + ("plain.rs", "pub mod inner { pub async fn helper() {} }"), + ("leafy.rs", "pub fn leaf() {}"), + ("broken.rs", "mod {{{ cron("), + ( + "gated.rs", + r#"#[cron("6 * * * * *")] pub async fn gated() {}"#, + ), + ("x.rs", r#"#[cron("7 * * * * *")] pub async fn pathed() {}"#), + ( + "stray.rs", + r#"#[cron("8 * * * * *")] pub async fn stray() {}"#, + ), + ]); + let src = krate.path().join("src"); + let early = src.join("early.rs").display().to_string(); + let jobs = discover(&[stored("early_job", Some(early))], Some(&src), None); + assert_eq!( + summary(&jobs), + vec![ + ( + "early".to_owned(), + "early_job".to_owned(), + "0 0 0 * * *".to_owned() + ), + ( + "inline".to_owned(), + "inline_job".to_owned(), + "0 0 * * * *".to_owned() + ), + ( + "late".to_owned(), + "late_job".to_owned(), + "2 * * * * *".to_owned() + ), + ( + "late::deeper".to_owned(), + "deep_job".to_owned(), + "5 * * * * *".to_owned() + ), + ] + ); + // Scanned jobs keep their `tz` / `overlap` arguments. + let late = jobs + .iter() + .find(|job| job.function_name == "late_job") + .unwrap(); + assert_eq!( + late.options, + CronOptions { + timezone: Some("Asia/Seoul".to_owned()), + skip_overlap: true, + } + ); +} + +/// Without a `cron(` attribute outside the storage-covered files nothing +/// is parsed and nothing is added. +#[rstest] +#[case::no_cron_anywhere(&[("lib.rs", "mod a;"), ("a.rs", "pub fn a() {}")], false)] +#[case::all_in_storage(&[("lib.rs", "mod a;"), ("a.rs", r#"#[cron("1 * * * * *")] pub async fn a() {}"#)], true)] +fn scan_adds_nothing_without_unexpanded_candidates( + #[case] files: &[(&str, &str)], + #[case] store_a: bool, +) { + let krate = crate_with(files); + let src = krate.path().join("src"); + let storage: Vec<_> = store_a + .then(|| stored("a", Some(src.join("a.rs").display().to_string()))) + .into_iter() + .collect(); + let jobs = discover(&storage, Some(&src), None); + assert_eq!(jobs.len(), storage.len()); +} + +/// Only the module tree holding the `vespera!` call site contributes. +#[rstest] +#[case::library_call_site(Some("lib.rs"), vec!["lib_job"])] +#[case::binary_call_site(Some("main.rs"), vec!["bin_job"])] +#[case::outside_both_trees(Some("orphan.rs"), vec![])] +#[case::binary_target(Some("bin/server.rs"), vec!["server_tasks_job", "server_job"])] +#[case::binary_dir_target(Some("bin/worker/main.rs"), vec!["worker_job"])] +#[case::unknown_call_site_prefers_library(None, vec!["lib_job"])] +fn scan_uses_the_tree_containing_the_call_site( + #[case] call_site: Option<&str>, + #[case] expected: Vec<&str>, +) { + let krate = crate_with(&[ + ( + "lib.rs", + r#"#[cron("1 * * * * *")] pub async fn lib_job() {}"#, + ), + ( + "main.rs", + r#"#[cron("2 * * * * *")] pub async fn bin_job() {}"#, + ), + ("orphan.rs", ""), + ( + "bin/server.rs", + r#"mod tasks; #[cron("3 * * * * *")] pub async fn server_job() {}"#, + ), + ( + "bin/tasks.rs", + r#"#[cron("4 * * * * *")] pub async fn server_tasks_job() {}"#, + ), + ( + "bin/worker/main.rs", + r#"#[cron("5 * * * * *")] pub async fn worker_job() {}"#, + ), + ("bin/README.md", "not a crate root"), + ]); + let src = krate.path().join("src"); + let call_site = call_site.map(|file| src.join(file)); + let jobs = discover(&[], Some(&src), call_site.as_deref()); + let names: Vec<_> = jobs.iter().map(|job| job.function_name.as_str()).collect(); + assert_eq!(names, expected); +} + +/// A job stored from a binary's root file lives at that crate's root, +/// not at `crate::bin::server`. +#[test] +fn binary_root_jobs_are_placed_at_the_crate_root() { + let krate = crate_with(&[( + "bin/server.rs", + r#"#[cron("1 * * * * *")] pub async fn tick() {}"#, + )]); + let src = krate.path().join("src"); + let server = src.join("bin").join("server.rs"); + let storage = [stored("tick", Some(server.display().to_string()))]; + let jobs = discover(&storage, Some(&src), Some(&server)); + assert_eq!(jobs.len(), 1); + assert_eq!(jobs[0].module_path, ""); +} + +#[test] +fn crate_without_a_root_file_contributes_nothing() { + let krate = crate_with(&[("jobs.rs", r#"#[cron("1 * * * * *")] pub async fn job() {}"#)]); + let src = krate.path().join("src"); + assert!(discover(&[], Some(&src), None).is_empty()); +} + +/// A bare `#[cron]` that a module imports from another crate is not +/// vespera's: it is never scheduled. `vespera::cron` always is, and an +/// import from vespera keeps the bare form vespera's. +#[rstest] +#[case::foreign_import("use other::cron;", &[("", "spelled", "2 * * * * *")])] +#[case::foreign_group("use other::{cron, x};", &[("", "spelled", "2 * * * * *")])] +#[case::foreign_rename("use other::schedule as cron;", &[("", "spelled", "2 * * * * *")])] +#[case::vespera_import("use vespera::cron;", &[("", "bare", "1 * * * * *"), ("", "spelled", "2 * * * * *")])] +#[case::vespera_group("use ::vespera::{cron, axum};", &[("", "bare", "1 * * * * *"), ("", "spelled", "2 * * * * *")])] +#[case::glob_import("use other::*;", &[("", "bare", "1 * * * * *"), ("", "spelled", "2 * * * * *")])] +#[case::other_cron_name("use other::cron as tick;", &[("", "bare", "1 * * * * *"), ("", "spelled", "2 * * * * *")])] +fn foreign_cron_attributes_are_not_claimed( + #[case] import: &str, + #[case] expected: &[(&str, &str, &str)], +) { + let source = format!( + r#"{import} #[cron("1 * * * * *")] pub async fn bare() {{}} #[vespera::cron("2 * * * * *")] pub async fn spelled() {{}}"# + ); + let krate = crate_with(&[("lib.rs", &source)]); + let src = krate.path().join("src"); + let jobs = discover(&[], Some(&src), None); + assert_eq!(summary(&jobs), rows(expected)); +} + +fn rows(rows: &[(&str, &str, &str)]) -> Vec<(String, String, String)> { + let mut rows: Vec<_> = rows + .iter() + .map(|(module, name, expr)| ((*module).to_owned(), (*name).to_owned(), (*expr).to_owned())) + .collect(); + rows.sort(); + rows +} + +/// Stored jobs are placed by the module tree: the crate root is `crate` +/// (not `crate::lib`), inline modules keep their segments (also +/// `#[cfg]`-gated ones rustc expanded), and a job declared after +/// `vespera!` in a file with stored jobs is still scanned. +#[test] +fn stored_jobs_take_their_module_path_from_the_module_tree() { + let krate = crate_with(&[ + ( + "lib.rs", + r#" + #[vespera::cron("1 * * * * *")] pub async fn root_job() {} + pub mod jobs { + #[cron("2 * * * * *")] pub async fn inline_job() {} + pub mod deeper { + #[cron("3 * * * * *")] pub async fn deep_job() {} + } + } + #[path = "x.rs"] mod pathed; + pub fn create_app() {} + #[cron("4 * * * * *")] pub async fn after_job() {} + #[cfg(test)] + mod tests { + #[cron("5 * * * * *")] pub async fn gated_job() {} + } + "#, + ), + ( + "x.rs", + r#"#[cron("6 * * * * *")] pub async fn pathed_job() {}"#, + ), + ]); + let src = krate.path().join("src"); + let lib = src.join("lib.rs").display().to_string(); + let x = src.join("x.rs").display().to_string(); + let storage = [ + stored("root_job", Some(lib.clone())), + stored("inline_job", Some(lib.clone())), + stored("deep_job", Some(lib.clone())), + stored("gated_job", Some(lib)), + stored("pathed_job", Some(x)), + ]; + let stored_expr = "0 0 0 * * *"; + let jobs = discover(&storage, Some(&src), Some(&src.join("lib.rs"))); + assert_eq!( + summary(&jobs), + rows(&[ + ("", "root_job", stored_expr), + ("jobs", "inline_job", stored_expr), + ("jobs::deeper", "deep_job", stored_expr), + ("tests", "gated_job", stored_expr), + // `#[path]` modules are not followed: derived from the file. + ("x", "pathed_job", stored_expr), + ("", "after_job", "4 * * * * *"), + ]) + ); +} + +/// Without a module tree to consult (call site outside both trees), a +/// stored crate-root job still resolves to `crate`. +#[test] +fn stored_root_job_without_a_tree_falls_back_to_the_crate_root() { + let krate = crate_with(&[ + ( + "main.rs", + r#"#[cron("1 * * * * *")] pub async fn bin_job() {}"#, + ), + ("orphan.rs", ""), + ]); + let src = krate.path().join("src"); + let main = src.join("main.rs").display().to_string(); + let jobs = discover( + &[stored("bin_job", Some(main))], + Some(&src), + Some(&src.join("orphan.rs")), + ); + assert_eq!(summary(&jobs), rows(&[("", "bin_job", "0 0 0 * * *")])); +} diff --git a/crates/vespera_macro/src/vespera_impl/export_names.rs b/crates/vespera_macro/src/vespera_impl/export_names.rs new file mode 100644 index 00000000..697862e2 --- /dev/null +++ b/crates/vespera_macro/src/vespera_impl/export_names.rs @@ -0,0 +1,157 @@ +//! One `export_app!` name per crate. +//! +//! An export's spec sidecar (`export//.openapi.json`), its +//! `include_str!` stem, its build cache and its `__VESPERA_EXPORT_KEY` are all +//! keyed by `/`: two `export_app!(App)` in different modules of +//! one crate (`mod admin` / `mod public`) overwrote each other's spec, so a +//! parent merged the wrong one, and each pruned the other's `include_str!` +//! target. The second one is a compile error instead. + +use std::collections::HashMap; +use std::path::Path; +use std::sync::{LazyLock, Mutex, PoisonError}; + +/// Where one `export_app!` was expanded. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ExportSite { + pub(super) file: String, + pub(super) line: usize, + pub(super) column: usize, +} + +/// `(crate build, App)` → the site that exported it. +type Sites = HashMap<(String, String), ExportSite>; + +static EXPORT_SITES: LazyLock> = LazyLock::new(|| Mutex::new(HashMap::new())); + +/// The current expansion's site: the call-site file and the name's position +/// (`None` outside the compiler, where spans carry no file). +pub(super) fn current_site(name: &syn::Ident) -> Option { + proc_macro2::Span::call_site() + .local_file() + .map(|file| site_in(&file, name)) +} + +/// `name`'s position in `file`. +fn site_in(file: &Path, name: &syn::Ident) -> ExportSite { + let start = name.span().start(); + ExportSite { + file: file.display().to_string(), + line: start.line, + column: start.column, + } +} + +/// Claims `app` for the crate build `krate` at `site`. Re-expanding the +/// same site (rust-analyzer) is fine, and a rename there frees the old name; +/// an earlier site whose file no longer mentions `app` is a stale IDE +/// leftover and is replaced. +pub(super) fn claim_export_name( + krate: &str, + app: &str, + site: Option, +) -> Result<(), String> { + let Some(site) = site else { + return Ok(()); + }; + let mut sites = EXPORT_SITES.lock().unwrap_or_else(PoisonError::into_inner); + sites.retain(|(k, a), s| !(k == krate && *s == site && a != app)); + let key = (krate.to_string(), app.to_string()); + if let Some(existing) = sites.get(&key) + && *existing != site + && still_mentions(existing, app) + { + return Err(format!( + "export_app! macro: `{app}` is already exported by this crate ({}, line {}); every export_app! of a crate needs a distinct name (its OpenAPI sidecar, cache and export key are named `/{app}`)", + Path::new(&existing.file).file_name().map_or_else( + || existing.file.clone(), + |name| name.to_string_lossy().into_owned() + ), + existing.line + )); + } + sites.insert(key, site); + Ok(()) +} + +/// Whether `site`'s file (still) mentions `app` — an unreadable one does not. +fn still_mentions(site: &ExportSite, app: &str) -> bool { + crate::schema_macro::file_cache::get_file_content(Path::new(&site.file)) + .is_some_and(|content| content.contains(app)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Outside the compiler the call site has no file: no site. + #[test] + fn sites_come_from_the_call_site_file() { + let name = syn::Ident::new("App", proc_macro2::Span::call_site()); + assert_eq!(current_site(&name), None); + let site = site_in(Path::new("src/lib.rs"), &name); + assert_eq!(site.file, "src/lib.rs"); + } + + fn site(file: &Path, line: usize) -> ExportSite { + let file = file.display().to_string(); + let column = 4; + ExportSite { file, line, column } + } + + /// A second site of one name is rejected; the same site, another crate + /// build, a stale site and a renamed site are not. + #[test] + fn duplicate_export_names_are_rejected() { + let dir = tempfile::TempDir::new().unwrap(); + let file = dir.path().join("lib.rs"); + std::fs::write( + &file, + "mod admin { export_app!(DupApp); }\nmod public { export_app!(DupApp); }", + ) + .unwrap(); + crate::schema_macro::file_cache::bump_epoch(); + let krate = format!("{}/dup", dir.path().display()); + + assert_eq!(claim_export_name(&krate, "DupApp", None), Ok(())); + assert_eq!( + claim_export_name(&krate, "DupApp", Some(site(&file, 1))), + Ok(()) + ); + assert_eq!( + claim_export_name(&krate, "DupApp", Some(site(&file, 1))), + Ok(()) + ); + let error = claim_export_name(&krate, "DupApp", Some(site(&file, 2))).unwrap_err(); + assert!( + error.contains("`DupApp` is already exported by this crate (lib.rs, line 1)"), + "{error}" + ); + assert!(error.ends_with("named `/DupApp`)"), "{error}"); + assert_eq!( + claim_export_name(&format!("{krate}.bin"), "DupApp", Some(site(&file, 2))), + Ok(()) + ); + + // Renamed at line 1: the name is free for line 2. + assert_eq!( + claim_export_name(&krate, "Renamed", Some(site(&file, 1))), + Ok(()) + ); + assert_eq!( + claim_export_name(&krate, "DupApp", Some(site(&file, 2))), + Ok(()) + ); + + // A site whose file no longer exports the name is stale. + let gone = dir.path().join("gone.rs"); + assert_eq!( + claim_export_name(&krate, "Other", Some(site(&gone, 1))), + Ok(()) + ); + assert_eq!( + claim_export_name(&krate, "Other", Some(site(&file, 3))), + Ok(()) + ); + } +} diff --git a/crates/vespera_macro/src/vespera_impl/merge_lookup.rs b/crates/vespera_macro/src/vespera_impl/merge_lookup.rs index 08df6ff8..28fa4b51 100644 --- a/crates/vespera_macro/src/vespera_impl/merge_lookup.rs +++ b/crates/vespera_macro/src/vespera_impl/merge_lookup.rs @@ -97,11 +97,20 @@ fn collect_use( collect_use(&path.tree, prefix, aliases); prefix.pop(); } + // `use child::{self, X}` binds `child` itself, not a name `self`. + syn::UseTree::Name(name) if name.ident == "self" => { + if let Some(last) = prefix.last() { + aliases.insert(last.clone(), prefix.clone()); + } + } syn::UseTree::Name(name) => { let mut full = prefix.clone(); full.push(name.ident.to_string()); aliases.insert(name.ident.to_string(), full); } + syn::UseTree::Rename(rename) if rename.ident == "self" => { + aliases.insert(rename.rename.to_string(), prefix.clone()); + } syn::UseTree::Rename(rename) => { let mut full = prefix.clone(); full.push(rename.ident.to_string()); @@ -410,6 +419,27 @@ mod tests { cache.check_unambiguous(&merge).unwrap(); } + /// `use child::{self, X}` (and `self as c`) alias the module itself. + #[test] + fn self_imports_alias_the_module() { + let temp = TempDir::new().unwrap(); + let file = temp.path().join("lib.rs"); + std::fs::write( + &file, + "use child::{self, ChildApp}; use other::{self as o}; use {self};", + ) + .unwrap(); + crate::schema_macro::file_cache::bump_epoch(); + let aliases = use_aliases(Some(&file)); + assert_eq!(aliases.get("child"), Some(&vec!["child".to_string()])); + assert_eq!(aliases.get("o"), Some(&vec!["other".to_string()])); + assert_eq!( + aliases.get("ChildApp"), + Some(&vec!["child".to_string(), "ChildApp".to_string()]) + ); + assert!(!aliases.contains_key("self")); + } + #[test] fn aliases_without_a_readable_call_site_are_empty() { assert!(use_aliases(None).is_empty()); diff --git a/crates/vespera_macro/src/vespera_impl/openapi_io.rs b/crates/vespera_macro/src/vespera_impl/openapi_io.rs index 2afe287b..447c0b60 100644 --- a/crates/vespera_macro/src/vespera_impl/openapi_io.rs +++ b/crates/vespera_macro/src/vespera_impl/openapi_io.rs @@ -337,34 +337,31 @@ pub(super) fn load_validated_sidecar_specs( spec_json_fingerprint: Option, spec_pretty_fingerprint: Option, ) -> Option { - let spec_tokens = match (spec_json_hash, spec_json_fingerprint) { - (None, _) => None, - (Some(expected_hash), Some(expected_fingerprint)) => { + // A missing stored fingerprint (mtime unavailable when it was written) + // is "unknown": the content hash decides, as `path_fingerprint` documents. + let spec_tokens = match spec_json_hash { + None => None, + Some(expected_hash) => { let path = embed_spec_path(tag, expected_hash); - if !super::cache::sidecar_matches( - &path, - Some(expected_hash), - Some(expected_fingerprint), - ) { + if !super::cache::sidecar_matches(&path, Some(expected_hash), spec_json_fingerprint) { return None; } Some(embed_tokens(&path)) } - (Some(_), None) => return None, }; - let pretty = match (spec_pretty_hash, spec_pretty_fingerprint) { - (None, _) => None, - (Some(expected_hash), Some(expected_fingerprint)) => { + let pretty = match spec_pretty_hash { + None => None, + Some(expected_hash) => { let path = pretty_sidecar_path(tag, expected_hash); let content = std::fs::read_to_string(&path).ok()?; - if super::cache::path_fingerprint(&path) != Some(expected_fingerprint) - && super::cache::hash_str(&content) != expected_hash - { + let fingerprint_matches = spec_pretty_fingerprint.is_some_and(|fingerprint| { + super::cache::path_fingerprint(&path) == Some(fingerprint) + }); + if !fingerprint_matches && super::cache::hash_str(&content) != expected_hash { return None; } Some(content) } - (Some(_), None) => return None, }; Some(SidecarSpecs { pretty, diff --git a/crates/vespera_macro/src/vespera_impl/openapi_io/tests.rs b/crates/vespera_macro/src/vespera_impl/openapi_io/tests.rs index 72f741e4..86acd854 100644 --- a/crates/vespera_macro/src/vespera_impl/openapi_io/tests.rs +++ b/crates/vespera_macro/src/vespera_impl/openapi_io/tests.rs @@ -13,6 +13,13 @@ use crate::vespera_impl::cache::hash_str; /// A fresh tempdir posing as `CARGO_MANIFEST_DIR` for this test thread only, /// so the `target/vespera/` spec sidecars `generate_and_write_openapi` writes /// never collide with another concurrently running test's. +/// The `vespera` sidecar directory the code under test resolves for the +/// crate at `manifest` (`find_target_dir`, so `CARGO_TARGET_DIR` / +/// `VESPERA_TARGET_DIR` are honoured like in the build). +fn vespera_dir_of(manifest: &std::path::Path) -> std::path::PathBuf { + find_target_dir(manifest).join("vespera") +} + fn isolated_target() -> (TempDir, EnvGuard) { let dir = TempDir::new().expect("Failed to create temp dir"); let guard = test_env::set_var("CARGO_MANIFEST_DIR", dir.path()); @@ -305,7 +312,7 @@ fn test_generate_and_write_openapi_with_merge_and_valid_spec() { let temp_dir = TempDir::new().expect("Failed to create temp dir"); // Create the vespera directory with a spec file - let target_dir = temp_dir.path().join("target").join("vespera"); + let target_dir = vespera_dir_of(temp_dir.path()); fs::create_dir_all(&target_dir).expect("Failed to create target/vespera dir"); // Write a valid OpenAPI spec file @@ -351,7 +358,7 @@ fn test_generate_and_write_openapi_with_merge_and_valid_spec() { #[test] fn merged_child_schema_conflict_is_a_spanned_compile_error() { let temp_dir = TempDir::new().unwrap(); - let target_dir = temp_dir.path().join("target/vespera"); + let target_dir = vespera_dir_of(temp_dir.path()); fs::create_dir_all(&target_dir).unwrap(); let spec = |property: &str, schema_type: &str| { serde_json::json!({ @@ -414,7 +421,7 @@ fn merged_child_schema_conflict_is_a_spanned_compile_error() { fn merge_resolves_child_specs_through_a_symlinked_manifest_dir() { let temp_dir = TempDir::new().unwrap(); let real = temp_dir.path().join("real"); - let target_dir = real.join("target/vespera"); + let target_dir = vespera_dir_of(&real); fs::create_dir_all(&target_dir).unwrap(); let child = serde_json::json!({ "openapi": "3.1.0", @@ -590,7 +597,7 @@ fn merge_input(path: syn::Path) -> ProcessedVesperaInput { #[test] fn merge_reports_missing_and_malformed_child_specs() { let temp = TempDir::new().unwrap(); - let vespera_dir = temp.path().join("target/vespera"); + let vespera_dir = vespera_dir_of(temp.path()); fs::create_dir_all(&vespera_dir).unwrap(); let _env_guard = test_env::set_var("CARGO_MANIFEST_DIR", temp.path()); @@ -637,7 +644,7 @@ fn ensure_cached_openapi_reports_parent_creation_error() { fn sidecar_validation_covers_missing_fingerprints_corruption_and_success() { let temp = TempDir::new().unwrap(); let _env_guard = test_env::set_var("CARGO_MANIFEST_DIR", temp.path()); - let vespera_dir = temp.path().join("target/vespera"); + let vespera_dir = vespera_dir_of(temp.path()); fs::create_dir_all(&vespera_dir).unwrap(); let compact = embed_spec_path("t", hash_str("compact")); let pretty = pretty_sidecar_path("t", hash_str("pretty")); @@ -646,11 +653,18 @@ fn sidecar_validation_covers_missing_fingerprints_corruption_and_success() { let compact_fp = path_fingerprint(&compact).unwrap(); let pretty_fp = path_fingerprint(&pretty).unwrap(); + // No stored fingerprint: the content hash decides. + assert!( + load_validated_sidecar_specs("t", Some(hash_str("compact")), None, None, None).is_some() + ); assert!( - load_validated_sidecar_specs("t", Some(hash_str("compact")), None, None, None).is_none() + load_validated_sidecar_specs("t", None, Some(hash_str("pretty")), None, None).is_some() ); + let wrong = embed_spec_path("t", hash_str("other")); + fs::write(&wrong, "not other").unwrap(); + assert!(load_validated_sidecar_specs("t", Some(hash_str("other")), None, None, None).is_none()); assert!( - load_validated_sidecar_specs("t", None, Some(hash_str("pretty")), None, None).is_none() + load_validated_sidecar_specs("t", None, Some(hash_str("missing")), None, None).is_none() ); assert!( load_validated_sidecar_specs( @@ -713,8 +727,8 @@ fn sidecar_validation_accepts_cache_without_generated_specs() { fn embedding_reports_directory_creation_and_spec_write_errors() { let create_temp = TempDir::new().unwrap(); let _env_guard = test_env::set_var("CARGO_MANIFEST_DIR", create_temp.path()); - fs::create_dir_all(create_temp.path().join("target")).unwrap(); - fs::write(create_temp.path().join("target/vespera"), "blocking file").unwrap(); + fs::create_dir_all(vespera_dir_of(create_temp.path()).parent().unwrap()).unwrap(); + fs::write(vespera_dir_of(create_temp.path()), "blocking file").unwrap(); let Err(create_error) = write_spec_for_embedding("t", Some("{}".to_string())) else { panic!("blocking parent must fail directory creation"); }; @@ -742,7 +756,7 @@ fn embedding_reports_directory_creation_and_spec_write_errors() { #[test] fn merged_children_with_same_operation_id_is_a_compile_error() { let temp_dir = TempDir::new().unwrap(); - let target_dir = temp_dir.path().join("target/vespera"); + let target_dir = vespera_dir_of(temp_dir.path()); fs::create_dir_all(&target_dir).unwrap(); let spec = |path: &str| { serde_json::json!({ @@ -783,13 +797,6 @@ fn merged_children_with_same_operation_id_is_a_compile_error() { /// A parent route may reference a security scheme that only a merged child /// declares: validation runs against the MERGED `components.securitySchemes`. /// A name declared nowhere is still rejected. -/// -/// `#[serial]` MUST follow the `#[case]` list. Placed between `#[rstest]` and -/// the cases, the generated case functions did not hold the serial lock -/// (`serial_test::is_locked_serially` was false inside them), so they raced -/// every other `CARGO_MANIFEST_DIR`-mutating test in this crate — the cause -/// of the flaky merge / sidecar / from_model failures under multi-threaded -/// `cargo test`. #[rstest::rstest] #[case::declared_by_child("childAuth", true, true)] #[case::declared_by_child_without_doc("childAuth", true, false)] @@ -802,7 +809,7 @@ fn security_references_are_validated_after_merge( #[case] with_doc: bool, ) { let temp_dir = TempDir::new().unwrap(); - let target_dir = temp_dir.path().join("target/vespera"); + let target_dir = vespera_dir_of(temp_dir.path()); fs::create_dir_all(&target_dir).unwrap(); let child = serde_json::json!({ "openapi": "3.1.0", @@ -871,7 +878,7 @@ fn security_references_are_validated_after_merge( #[test] fn security_without_doc_skips_unusable_children() { let temp_dir = TempDir::new().unwrap(); - let target_dir = temp_dir.path().join("target/vespera"); + let target_dir = vespera_dir_of(temp_dir.path()); fs::write( crate::vespera_impl::merge_lookup::child_spec_path(&target_dir, "broken", "Broken"), "{ not json", diff --git a/crates/vespera_macro/src/vespera_impl/orchestrator.rs b/crates/vespera_macro/src/vespera_impl/orchestrator.rs index 5f13fe7c..b36bbc46 100644 --- a/crates/vespera_macro/src/vespera_impl/orchestrator.rs +++ b/crates/vespera_macro/src/vespera_impl/orchestrator.rs @@ -160,6 +160,15 @@ pub fn process_vespera_macro( let mut metadata = cache.metadata; finalize_metadata(&mut metadata, schema_storage, route_storage, "vespera!")?; stage("cache_branch_metadata_merge"); + // A cache hit only proves the parent's own sources are unchanged — a + // child's routes can still collide with them. Checked before any + // file is written, so a failed build never replaces the spec. + check_merge_route_conflicts( + &metadata, + &doc_routes(processed), + &processed.merge, + &mut merge_specs, + )?; // Ensure openapi.json files exist and are up-to-date from cache ensure_openapi_files_from_cache(&processed.openapi_file_names, sidecars.pretty.as_deref())?; @@ -183,6 +192,12 @@ pub fn process_vespera_macro( // passed, so the check would be redundant. crate::parser::validate_schema_backed_extractors_with_cache(&metadata, &file_asts)?; stage("validate_schema_backed_extractors"); + check_merge_route_conflicts( + &metadata, + &doc_routes(processed), + &processed.merge, + &mut merge_specs, + )?; let openapi = generate_and_write_openapi( processed, @@ -223,14 +238,6 @@ pub fn process_vespera_macro( (metadata, spec_tokens) }; - // Both branches: a cache hit only proves the parent's own sources are - // unchanged — a child's routes can still collide with them. - check_merge_route_conflicts( - &metadata, - &doc_routes(processed), - &processed.merge, - &mut merge_specs, - )?; let export_checks = merged_export_checks(&processed.merge, &merge_specs); // --- Cron job discovery: CRON_STORAGE + module-tree scan fallback --- @@ -243,6 +250,8 @@ pub fn process_vespera_macro( let storage = crate::macro_storage::retain_declared(&storage); let src_dir = crate::env_access::var("CARGO_MANIFEST_DIR") .ok() + // Without the `cron` feature no `#[cron]` is vespera's: never scan. + .filter(|_| cfg!(feature = "cron")) .map(|dir| PathBuf::from(dir).join("src")); let call_site = Span::call_site().local_file(); discover_cron_jobs(&storage, src_dir.as_deref(), call_site.as_deref()) @@ -318,6 +327,20 @@ pub fn process_export_app( None }; + // One sidecar / cache / key per `/`: a second same-named + // export of this crate build is rejected (`export_names`). + let build = format!( + "{manifest_dir}/{}{}", + current_export_crate(), + super::path_utils::target_variant() + ); + super::export_names::claim_export_name( + &build, + &name.to_string(), + super::export_names::current_site(name), + ) + .map_err(|msg| syn::Error::new(name.span(), msg))?; + let folder_path = find_folder_path(folder_name)?; if !folder_path.exists() { return Err(syn::Error::new( diff --git a/crates/vespera_macro/src/vespera_impl/orchestrator/tests_audit.rs b/crates/vespera_macro/src/vespera_impl/orchestrator/tests_audit.rs index 095475fc..fa78d745 100644 --- a/crates/vespera_macro/src/vespera_impl/orchestrator/tests_audit.rs +++ b/crates/vespera_macro/src/vespera_impl/orchestrator/tests_audit.rs @@ -90,6 +90,7 @@ fn doc_route_shadowing_an_app_route_is_rejected() { /// router must mount only functions the file still declares. #[test] fn stale_storage_entries_of_renamed_handlers_are_ignored() { + let _ide = crate::macro_storage::test_ide::force(); let temp_dir = TempDir::new().unwrap(); let _manifest = crate::env_access::test_env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()); let routes = temp_dir.path().join("src").join("routes"); @@ -190,6 +191,45 @@ fn merged_and_exported_apps_carry_the_export_key() { ); } +/// A merged child's route colliding with the parent's is rejected BEFORE the +/// parent's `openapi.json` is written: a failed build keeps the old spec. +#[test] +fn merge_route_conflict_writes_no_spec() { + let temp_dir = TempDir::new().unwrap(); + let _manifest = crate::env_access::test_env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()); + let _crate = crate::env_access::test_env::set_var("CARGO_CRATE_NAME", "parent"); + let routes = temp_dir.path().join("src").join("routes"); + fs::create_dir_all(&routes).unwrap(); + fs::write( + routes.join("items.rs"), + "#[vespera::route(get)] pub async fn list() {} +", + ) + .unwrap(); + crate::schema_macro::file_cache::bump_epoch(); + process_export_app( + &syn::parse_quote!(ClashApp), + "routes", + &HashMap::new(), + &temp_dir.path().to_string_lossy(), + &[], + "", + Span::call_site(), + ) + .unwrap(); + + let spec = temp_dir.path().join("openapi.json"); + let input = ProcessedVesperaInput { + merge: vec![syn::parse_quote!(crate::ClashApp)], + openapi_file_names: vec![spec.display().to_string()], + ..spec_less("routes") + }; + let err = process_vespera_macro(&input, &HashMap::new(), &[], Span::call_site()) + .expect_err("the child's GET /items collides"); + assert!(err.to_string().contains("/items"), "{err}"); + assert!(!spec.exists(), "a failed build must not write the spec"); +} + /// The same check guards `export_app!`. #[test] fn duplicate_route_is_rejected_by_export_app() { diff --git a/crates/vespera_macro/src/vespera_impl/path_utils.rs b/crates/vespera_macro/src/vespera_impl/path_utils.rs index 87231b11..5b847936 100644 --- a/crates/vespera_macro/src/vespera_impl/path_utils.rs +++ b/crates/vespera_macro/src/vespera_impl/path_utils.rs @@ -50,10 +50,7 @@ pub(super) fn target_variant() -> String { /// `rustc --test` harness build (the only trace of `cfg(test)` a proc macro /// can observe), else `""`. fn variant_of(args: &[String], exe: Option<&Path>) -> &'static str { - let in_ide = exe - .and_then(Path::file_name) - .is_some_and(|name| name.to_string_lossy().contains("rust-analyzer")); - if in_ide { + if crate::macro_storage::is_rust_analyzer(exe) { ".ide" } else if args.iter().any(|arg| arg == "--test") { ".test" diff --git a/crates/vespera_macro/tests/trybuild_diagnostics.rs b/crates/vespera_macro/tests/trybuild_diagnostics.rs index b9287a49..38b1bca6 100644 --- a/crates/vespera_macro/tests/trybuild_diagnostics.rs +++ b/crates/vespera_macro/tests/trybuild_diagnostics.rs @@ -19,6 +19,7 @@ fn ui_diagnostics() { // `vespera!` t.compile_fail("tests/ui/vespera_args_invalid.rs"); t.compile_fail("tests/ui/export_app_dir_invalid.rs"); + t.compile_fail("tests/ui/export_app_duplicate_name.rs"); t.compile_fail("tests/ui/vespera_security_servers_invalid.rs"); t.compile_fail("tests/ui/vespera_oauth2_security_invalid.rs"); // `#[derive(Schema)]` diff --git a/crates/vespera_macro/tests/ui/export_app_duplicate_name.rs b/crates/vespera_macro/tests/ui/export_app_duplicate_name.rs new file mode 100644 index 00000000..9649207e --- /dev/null +++ b/crates/vespera_macro/tests/ui/export_app_duplicate_name.rs @@ -0,0 +1,11 @@ +//! Two `export_app!`s of one crate may not share a name: their OpenAPI +//! sidecar, cache and export key are all named `/`. +mod admin { + vespera_macro::export_app!(App, dir = "missing_admin_routes"); +} + +mod public { + vespera_macro::export_app!(App, dir = "missing_public_routes"); +} + +fn main() {} diff --git a/crates/vespera_macro/tests/ui/export_app_duplicate_name.stderr b/crates/vespera_macro/tests/ui/export_app_duplicate_name.stderr new file mode 100644 index 00000000..aa0c25ba --- /dev/null +++ b/crates/vespera_macro/tests/ui/export_app_duplicate_name.stderr @@ -0,0 +1,11 @@ +error: export_app! macro: route folder 'missing_admin_routes' not found. Create src/missing_admin_routes or specify a different folder with `dir = "your_folder"`. + --> tests/ui/export_app_duplicate_name.rs:4:43 + | +4 | vespera_macro::export_app!(App, dir = "missing_admin_routes"); + | ^^^^^^^^^^^^^^^^^^^^^^ + +error: export_app! macro: `App` is already exported by this crate (export_app_duplicate_name.rs, line 4); every export_app! of a crate needs a distinct name (its OpenAPI sidecar, cache and export key are named `/App`) + --> tests/ui/export_app_duplicate_name.rs:8:32 + | +8 | vespera_macro::export_app!(App, dir = "missing_public_routes"); + | ^^^ diff --git a/crates/vespera_macro/tests/ui/vespera_oauth2_security_invalid.rs b/crates/vespera_macro/tests/ui/vespera_oauth2_security_invalid.rs index 97515c45..a1cf9ea2 100644 --- a/crates/vespera_macro/tests/ui/vespera_oauth2_security_invalid.rs +++ b/crates/vespera_macro/tests/ui/vespera_oauth2_security_invalid.rs @@ -1,5 +1,5 @@ //! OAuth2 / scoped-security DSL errors: an `oauth2` scheme without `flows`, a -//! flow missing a URL OpenAPI requires, a top-level requirement asking for a +//! flow missing a URL OpenAPI requires, a URL its flow does not take, a top-level requirement asking for a //! scope no flow declares, and a malformed `#[route(security = [...])]` entry. use vespera_macro::{route, vespera}; @@ -15,6 +15,17 @@ fn flow_missing_token_url() { }]); } +fn implicit_flow_with_token_url() { + let _ = vespera!(security_schemes = [{ + name = "oauth2", + type = "oauth2", + flows = { implicit = { + authorization_url = "https://auth.example.com/authorize", + token_url = "https://auth.example.com/token", + } }, + }]); +} + fn undeclared_scope() { let _ = vespera!( security_schemes = [{ diff --git a/crates/vespera_macro/tests/ui/vespera_oauth2_security_invalid.stderr b/crates/vespera_macro/tests/ui/vespera_oauth2_security_invalid.stderr index a75499ad..1d26962d 100644 --- a/crates/vespera_macro/tests/ui/vespera_oauth2_security_invalid.stderr +++ b/crates/vespera_macro/tests/ui/vespera_oauth2_security_invalid.stderr @@ -12,14 +12,20 @@ error: OAuth2 `authorization_code` flow is missing required field `token_url` 14 | flows = { authorization_code = { authorization_url = "https://auth.example.com/authorize" } }, | ^^^^^^^^^^^^^^^^^^ +error: OAuth2 `implicit` flow does not take `token_url`; it uses `authorization_url` + --> tests/ui/vespera_oauth2_security_invalid.rs:24:13 + | +24 | token_url = "https://auth.example.com/token", + | ^^^^^^^^^ + error: top-level `security` requires scope `admin`, which no flow of oauth2 security scheme `oauth2` declares; declared scopes: `read:users` - --> tests/ui/vespera_oauth2_security_invalid.rs:30:20 + --> tests/ui/vespera_oauth2_security_invalid.rs:41:20 | -30 | security = [("oauth2", ["read:users", "admin"])], +41 | security = [("oauth2", ["read:users", "admin"])], | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ error: #[route] `security` entries must be a scheme name (`"bearerAuth"`), a scheme with scopes (`("oauth2", ["read:users"])`), or an AND group of those (`["apiKey", ("oauth2", ["read:users"])]`) - --> tests/ui/vespera_oauth2_security_invalid.rs:34:26 + --> tests/ui/vespera_oauth2_security_invalid.rs:45:26 | -34 | #[route(get, security = [("oauth2", "read:users")])] +45 | #[route(get, security = [("oauth2", "read:users")])] | ^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index 263be2b7..fb3f9f26 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -1223,6 +1223,23 @@ } } }, + "/health/live": { + "get": { + "operationId": "liveness", + "responses": { + "200": { + "description": "Successful response", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/hello": { "get": { "operationId": "mod_file_endpoint", @@ -2971,6 +2988,37 @@ } } }, + "/uuid-items/clock": { + "post": { + "operationId": "echo_clock", + "tags": [ + "uuid_items" + ], + "summary": "Echo a clock, filling an omitted `createdAt` with the epoch", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClockDtoInput" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClockDto" + } + } + } + } + } + } + }, "/validated/pages/{page}": { "get": { "operationId": "validated_page", @@ -3897,6 +3945,44 @@ "name" ] }, + "ClockDto": { + "type": "object", + "description": "Clock model: a server-local `NOW()` timestamp (`DateTimeLocal`).", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Created at (server-local time)" + }, + "id": { + "type": "integer", + "format": "int32", + "description": "Clock ID", + "default": 0 + } + }, + "required": [ + "id", + "createdAt" + ] + }, + "ClockDtoInput": { + "type": "object", + "description": "Clock model: a server-local `NOW()` timestamp (`DateTimeLocal`).", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Created at (server-local time)" + }, + "id": { + "type": "integer", + "format": "int32", + "description": "Clock ID", + "default": 0 + } + } + }, "ComplexStructBody": { "type": "object", "properties": { @@ -5353,7 +5439,8 @@ "default": 0 }, "status": { - "$ref": "#/components/schemas/MemoStatus" + "$ref": "#/components/schemas/MemoStatus", + "default": "draft" }, "title": { "type": "string" @@ -5474,7 +5561,8 @@ } }, "status": { - "$ref": "#/components/schemas/MemoStatus" + "$ref": "#/components/schemas/MemoStatus", + "default": "draft" }, "title": { "type": "string" @@ -5528,7 +5616,8 @@ "default": 0 }, "status": { - "$ref": "#/components/schemas/MemoStatus" + "$ref": "#/components/schemas/MemoStatus", + "default": "draft" }, "title": { "type": "string" @@ -5614,7 +5703,8 @@ "default": 0 }, "status": { - "$ref": "#/components/schemas/MemoStatus" + "$ref": "#/components/schemas/MemoStatus", + "default": "draft" }, "title": { "type": "string" @@ -5654,7 +5744,8 @@ "default": 0 }, "status": { - "$ref": "#/components/schemas/MemoStatus" + "$ref": "#/components/schemas/MemoStatus", + "default": "draft" }, "title": { "type": "string" @@ -5735,7 +5826,8 @@ "type": "string" }, "status": { - "$ref": "#/components/schemas/MemoStatus" + "$ref": "#/components/schemas/MemoStatus", + "default": "draft" }, "title": { "type": "string" diff --git a/examples/axum-example/src/models/clock.rs b/examples/axum-example/src/models/clock.rs new file mode 100644 index 00000000..76296841 --- /dev/null +++ b/examples/axum-example/src/models/clock.rs @@ -0,0 +1,18 @@ +#![allow(dead_code)] + +use sea_orm::entity::prelude::*; + +/// Clock model: a server-local `NOW()` timestamp (`DateTimeLocal`). +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "clock")] +pub struct Model { + /// Clock ID + #[sea_orm(primary_key)] + pub id: i32, + /// Created at (server-local time) + #[sea_orm(default_value = "NOW()")] + pub created_at: DateTimeLocal, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/axum-example/src/models/mod.rs b/examples/axum-example/src/models/mod.rs index 50de075e..7afb3a97 100644 --- a/examples/axum-example/src/models/mod.rs +++ b/examples/axum-example/src/models/mod.rs @@ -2,6 +2,7 @@ pub mod api; pub mod bakery; pub mod cake; pub mod category; +pub mod clock; pub mod config; pub mod cycle_a; pub mod cycle_b; diff --git a/examples/axum-example/src/routes/codecs.rs b/examples/axum-example/src/routes/codecs.rs index f18292cf..fae47f7a 100644 --- a/examples/axum-example/src/routes/codecs.rs +++ b/examples/axum-example/src/routes/codecs.rs @@ -112,7 +112,12 @@ pub struct Money { impl From for MoneyWire { fn from(money: Money) -> Self { Self { - amount: format!("{}.{:02}", money.cents / 100, money.cents % 100), + amount: format!( + "{}{}.{:02}", + if money.cents < 0 { "-" } else { "" }, + money.cents.unsigned_abs() / 100, + money.cents.unsigned_abs() % 100 + ), currency: money.currency, } } diff --git a/examples/axum-example/src/routes/health.rs b/examples/axum-example/src/routes/health.rs index e2acfb77..377550f3 100644 --- a/examples/axum-example/src/routes/health.rs +++ b/examples/axum-example/src/routes/health.rs @@ -2,3 +2,16 @@ pub async fn health() -> &'static str { "ok" } + +/// A route a `macro_rules!` macro generates: this file never spells its +/// declaration out, yet `vespera!` mounts it. +macro_rules! probe_route { + ($name:ident, $path:literal, $body:literal) => { + #[vespera::route(get, path = $path)] + pub async fn $name() -> &'static str { + $body + } + }; +} + +probe_route!(liveness, "/live", "alive"); diff --git a/examples/axum-example/src/routes/uuid_items.rs b/examples/axum-example/src/routes/uuid_items.rs index c5918881..7c1e5f19 100644 --- a/examples/axum-example/src/routes/uuid_items.rs +++ b/examples/axum-example/src/routes/uuid_items.rs @@ -10,6 +10,16 @@ schema_type!(CreateUuidItemRequest from crate::models::uuid_item::Model, omit_de // A source path written through this module's import (`uuid_item::Model`) // resolves through it; it used to be read as `crate::uuid_item`. schema_type!(UuidItemName from uuid_item::Model, pick = ["id", "name"]); +// A cross-file `DateTimeLocal` field with a `NOW()` default: the value +// depends on the server's offset, so no OpenAPI default is advertised, yet +// the DTO compiles and serde fills an omitted `created_at`. +schema_type!(ClockDto from crate::models::clock::Model); + +/// Echo a clock, filling an omitted `createdAt` with the epoch +#[vespera::route(post, path = "/clock", tags = ["uuid_items"])] +pub async fn echo_clock(Json(clock): Json) -> Json { + Json(clock) +} /// List all UUID items #[vespera::route(get, tags = ["uuid_items"])] diff --git a/examples/axum-example/tests/integration_codecs.rs b/examples/axum-example/tests/integration_codecs.rs index 900ca26e..6e47e7ed 100644 --- a/examples/axum-example/tests/integration_codecs.rs +++ b/examples/axum-example/tests/integration_codecs.rs @@ -34,6 +34,18 @@ async fn custom_codecs_round_trip_in_their_documented_shape() { StatusCode::OK, json!({"amount": "12.34", "currency": "EUR"}) )] +#[case::negative_money( + "/codecs/money", + json!({"cents": -5, "currency": "EUR"}), + StatusCode::OK, + json!({"amount": "-0.05", "currency": "EUR"}) +)] +#[case::negative_money_above_one( + "/codecs/money", + json!({"cents": -105, "currency": "EUR"}), + StatusCode::OK, + json!({"amount": "-1.05", "currency": "EUR"}) +)] #[case::stamp("/codecs/stamp", json!(1_700_000_000), StatusCode::OK, json!(1_700_000_000))] #[case::json( "/codecs/json", diff --git a/examples/axum-example/tests/integration_multipart.rs b/examples/axum-example/tests/integration_multipart.rs index f68edd15..0fa1fe6e 100644 --- a/examples/axum-example/tests/integration_multipart.rs +++ b/examples/axum-example/tests/integration_multipart.rs @@ -133,20 +133,39 @@ async fn test_form_data_no_limit_field_accepts_large_data() { assert_eq!(result.len(), 10_000); } -#[tokio::test] -async fn test_form_data_limit_unlimited_keyword() { - // Verify that parse_byte_unit handles "unlimited" (code path: returns None) - // Tested indirectly: a field without a limit already behaves as unlimited. - // This test confirms the same behavior with all fields provided. - let server = TestServer::new(create_limit_test_app()); - - let form = MultipartForm::new() - .add_text("name", "test") - .add_text("data", "y".repeat(50)) - .add_part("file", Part::bytes(vec![1u8; 30]).file_name("f.bin")); - - let response = server.post("/limit-test").multipart(form).await; - response.assert_status_ok(); +/// `limit = "unlimited"` lifts the 256-byte cap an unannotated scalar field +/// gets: the same whitespace-padded `true` is rejected without it and parsed +/// with it. +#[derive(Debug, Multipart)] +struct UnlimitedScalarRequest { + #[form_data(limit = "unlimited")] + pub open: Option, + pub capped: Option, +} + +async fn unlimited_scalar_handler( + TypedMultipart(req): TypedMultipart, +) -> axum::Json<(Option, Option)> { + axum::Json((req.open, req.capped)) +} + +#[rstest::rstest] +#[case::unlimited("open", 200)] +#[case::default_cap("capped", 413)] +#[tokio::test] +async fn test_form_data_limit_unlimited_keyword(#[case] field: &str, #[case] status: u16) { + let app = + axum::Router::new().route("/unlimited", axum::routing::post(unlimited_scalar_handler)); + let server = TestServer::new(app); + let padded = format!("true{}", " ".repeat(1_000)); + let form = MultipartForm::new().add_text(field, &padded); + + let response = server.post("/unlimited").multipart(form).await; + response.assert_status(axum::http::StatusCode::from_u16(status).unwrap()); + if status == 200 { + let parsed: (Option, Option) = response.json(); + assert_eq!(parsed, (Some(true), None)); + } } // ============== #[serde(rename)] and #[serde(default)] tests ============== diff --git a/examples/axum-example/tests/integration_schema.rs b/examples/axum-example/tests/integration_schema.rs index 2f700214..cf9bae2b 100644 --- a/examples/axum-example/tests/integration_schema.rs +++ b/examples/axum-example/tests/integration_schema.rs @@ -35,6 +35,51 @@ pub struct TestUserCamelCase { pub email_address: String, } +#[derive(Serialize, Deserialize, Clone, Default, Schema)] +#[serde(rename_all = "lowercase")] +pub enum TestStatus { + #[default] + Active, + Archived, +} + +#[derive(Serialize, Deserialize, Clone, Schema)] +pub struct TestWithRefDefault { + #[serde(default)] + #[schema(default = "active")] + pub status: TestStatus, +} + +/// An explicit `#[schema(default)]` on a `$ref`-typed field is documented +/// as a sibling of the `$ref` (OpenAPI 3.1), not dropped. +#[test] +fn test_schema_macro_documents_ref_default() { + let schema = schema!(TestWithRefDefault); + let status = serde_json::to_value(&schema.properties.unwrap()["status"]).unwrap(); + assert_eq!( + status, + json!({ "$ref": "#/components/schemas/TestStatus", "default": "active" }) + ); +} + +/// `schema_type!` of a cross-file model whose `DateTimeLocal` field has a +/// `NOW()` default compiles (no OpenAPI default is advertised for a +/// machine-dependent value) and serde fills the omitted field with the epoch. +#[tokio::test] +async fn test_schema_type_local_now_default() { + let server = TestServer::new(create_app().await); + let response = server + .post("/uuid-items/clock") + .json(&json!({ "id": 7 })) + .await; + response.assert_status_ok(); + let clock: serde_json::Value = response.json(); + assert_eq!(clock["id"], 7); + let created_at = clock["createdAt"].as_str().unwrap(); + let parsed = vespera::chrono::DateTime::parse_from_rfc3339(created_at).unwrap(); + assert_eq!(parsed.timestamp(), 0, "{created_at}"); +} + #[test] fn test_schema_macro_full() { // Generate full schema for TestUser @@ -133,6 +178,12 @@ fn test_schema_macro_with_optional_fields() { let properties = user_schema.properties.unwrap(); assert_eq!(properties.len(), 4); + // The component schema's `default` too: `schema!` applies the same + // default post-processing. + let vespera::schema::SchemaRef::Inline(bio) = &properties["bio"] else { + panic!("inline bio"); + }; + assert_eq!(bio.default, Some(json!(""))); // Required is nullability-only, matching the OpenAPI component schema: // 'id'/'name' are non-Option, and 'bio' is non-Option too — its diff --git a/examples/axum-example/tests/integration_test.rs b/examples/axum-example/tests/integration_test.rs index 9a6e5a2f..1804dfe6 100644 --- a/examples/axum-example/tests/integration_test.rs +++ b/examples/axum-example/tests/integration_test.rs @@ -16,6 +16,18 @@ async fn test_health_endpoint() { response.assert_text("ok"); } +/// A `macro_rules!`-generated route is mounted (its file never spells the +/// declaration out for the stale-entry filter to find). +#[tokio::test] +async fn test_macro_generated_route_is_mounted() { + let server = TestServer::new(create_app().await); + + let response = server.get("/health/live").await; + + response.assert_status_ok(); + response.assert_text("alive"); +} + /// `Form` on GET reads the query string, as the document says. #[tokio::test] async fn test_form_on_get_reads_the_query_string() { diff --git a/examples/axum-example/tests/snapshots/integration_test__openapi.snap b/examples/axum-example/tests/snapshots/integration_test__openapi.snap index 49285776..85986af9 100644 --- a/examples/axum-example/tests/snapshots/integration_test__openapi.snap +++ b/examples/axum-example/tests/snapshots/integration_test__openapi.snap @@ -1227,6 +1227,23 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } } }, + "/health/live": { + "get": { + "operationId": "liveness", + "responses": { + "200": { + "description": "Successful response", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/hello": { "get": { "operationId": "mod_file_endpoint", @@ -2975,6 +2992,37 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } } }, + "/uuid-items/clock": { + "post": { + "operationId": "echo_clock", + "tags": [ + "uuid_items" + ], + "summary": "Echo a clock, filling an omitted `createdAt` with the epoch", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClockDtoInput" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClockDto" + } + } + } + } + } + } + }, "/validated/pages/{page}": { "get": { "operationId": "validated_page", @@ -3901,6 +3949,44 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "name" ] }, + "ClockDto": { + "type": "object", + "description": "Clock model: a server-local `NOW()` timestamp (`DateTimeLocal`).", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Created at (server-local time)" + }, + "id": { + "type": "integer", + "format": "int32", + "description": "Clock ID", + "default": 0 + } + }, + "required": [ + "id", + "createdAt" + ] + }, + "ClockDtoInput": { + "type": "object", + "description": "Clock model: a server-local `NOW()` timestamp (`DateTimeLocal`).", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Created at (server-local time)" + }, + "id": { + "type": "integer", + "format": "int32", + "description": "Clock ID", + "default": 0 + } + } + }, "ComplexStructBody": { "type": "object", "properties": { @@ -5357,7 +5443,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "default": 0 }, "status": { - "$ref": "#/components/schemas/MemoStatus" + "$ref": "#/components/schemas/MemoStatus", + "default": "draft" }, "title": { "type": "string" @@ -5478,7 +5565,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } }, "status": { - "$ref": "#/components/schemas/MemoStatus" + "$ref": "#/components/schemas/MemoStatus", + "default": "draft" }, "title": { "type": "string" @@ -5532,7 +5620,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "default": 0 }, "status": { - "$ref": "#/components/schemas/MemoStatus" + "$ref": "#/components/schemas/MemoStatus", + "default": "draft" }, "title": { "type": "string" @@ -5618,7 +5707,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "default": 0 }, "status": { - "$ref": "#/components/schemas/MemoStatus" + "$ref": "#/components/schemas/MemoStatus", + "default": "draft" }, "title": { "type": "string" @@ -5658,7 +5748,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "default": 0 }, "status": { - "$ref": "#/components/schemas/MemoStatus" + "$ref": "#/components/schemas/MemoStatus", + "default": "draft" }, "title": { "type": "string" @@ -5739,7 +5830,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "string" }, "status": { - "$ref": "#/components/schemas/MemoStatus" + "$ref": "#/components/schemas/MemoStatus", + "default": "draft" }, "title": { "type": "string" diff --git a/scripts/jni-bench-interleaved.sh b/scripts/jni-bench-interleaved.sh index 67dffa9d..c6ce8ff5 100755 --- a/scripts/jni-bench-interleaved.sh +++ b/scripts/jni-bench-interleaved.sh @@ -75,10 +75,11 @@ build_side() { # [ -n "$(ls -A "$work/lib-$side")" ] || { echo "no cdylib built for $side" >&2; exit 1; } (cd "$tree/libs/vespera-bridge" && sh ./gradlew jar --console=plain -q) - local jar - jar=$(find "$tree/libs/vespera-bridge/build/libs" -name 'vespera-bridge-*.jar' \ - ! -name '*-sources.jar' ! -name '*-javadoc.jar' | head -n 1) - [ -n "$jar" ] || { echo "no vespera-bridge jar built for $side" >&2; exit 1; } + # The JAR of THIS tree's version: build/libs may still hold older ones. + local jar version + version=$(sed -n 's/^version = "\([^"]*\)".*/\1/p' "$tree/libs/vespera-bridge/build.gradle.kts" | head -n 1) + jar="$tree/libs/vespera-bridge/build/libs/vespera-bridge-$version.jar" + [ -n "$version" ] && [ -f "$jar" ] || { echo "no vespera-bridge $version jar built for $side" >&2; exit 1; } cp "$jar" "$work/bridge-$side.jar" mkdir -p "$work/classes-$side"