A better Rust ATProto crate
at lifetimes 103 kB view raw
1use crate::corpus::LexiconCorpus; 2use crate::error::{CodegenError, Result}; 3use crate::lexicon::{ 4 LexArrayItem, LexInteger, LexObject, LexObjectProperty, LexRecord, LexString, LexStringFormat, 5 LexUserType, LexXrpcBody, LexXrpcBodySchema, LexXrpcError, LexXrpcProcedure, LexXrpcQuery, 6 LexXrpcSubscription, LexXrpcSubscriptionMessageSchema, 7}; 8use heck::{ToPascalCase, ToSnakeCase}; 9use proc_macro2::TokenStream; 10use quote::quote; 11 12/// Convert a value string to a valid Rust variant name 13fn value_to_variant_name(value: &str) -> String { 14 // Remove leading special chars and convert to pascal case 15 let clean = value.trim_start_matches(|c: char| !c.is_alphanumeric()); 16 let variant = clean.replace('-', "_").to_pascal_case(); 17 18 // Prefix with underscore if starts with digit 19 if variant.chars().next().map_or(false, |c| c.is_ascii_digit()) { 20 format!("_{}", variant) 21 } else if variant.is_empty() { 22 "Unknown".to_string() 23 } else { 24 variant 25 } 26} 27 28/// Sanitize a string to be safe for identifiers and filenames 29fn sanitize_name(s: &str) -> String { 30 if s.is_empty() { 31 return "unknown".to_string(); 32 } 33 34 // Replace invalid characters with underscores 35 let mut sanitized: String = s 36 .chars() 37 .map(|c| { 38 if c.is_alphanumeric() || c == '_' { 39 c 40 } else { 41 '_' 42 } 43 }) 44 .collect(); 45 46 // Ensure it doesn't start with a digit 47 if sanitized 48 .chars() 49 .next() 50 .map_or(false, |c| c.is_ascii_digit()) 51 { 52 sanitized = format!("_{}", sanitized); 53 } 54 55 sanitized 56} 57 58/// Create an identifier, using raw identifier if necessary for keywords 59fn make_ident(s: &str) -> syn::Ident { 60 if s.is_empty() { 61 eprintln!("Warning: Empty identifier encountered, using 'unknown' as fallback"); 62 return syn::Ident::new("unknown", proc_macro2::Span::call_site()); 63 } 64 65 let sanitized = sanitize_name(s); 66 67 // Try to parse as ident, fall back to raw ident if needed 68 syn::parse_str::<syn::Ident>(&sanitized).unwrap_or_else(|_| { 69 eprintln!( 70 "Warning: Invalid identifier '{}' sanitized to '{}'", 71 s, sanitized 72 ); 73 syn::Ident::new_raw(&sanitized, proc_macro2::Span::call_site()) 74 }) 75} 76 77/// Code generator for lexicon types 78pub struct CodeGenerator<'c> { 79 corpus: &'c LexiconCorpus, 80 root_module: String, 81} 82 83impl<'c> CodeGenerator<'c> { 84 /// Create a new code generator 85 pub fn new(corpus: &'c LexiconCorpus, root_module: impl Into<String>) -> Self { 86 Self { 87 corpus, 88 root_module: root_module.into(), 89 } 90 } 91 92 /// Generate code for a lexicon def 93 pub fn generate_def( 94 &self, 95 nsid: &str, 96 def_name: &str, 97 def: &LexUserType<'static>, 98 ) -> Result<TokenStream> { 99 match def { 100 LexUserType::Record(record) => self.generate_record(nsid, def_name, record), 101 LexUserType::Object(obj) => self.generate_object(nsid, def_name, obj), 102 LexUserType::XrpcQuery(query) => self.generate_query(nsid, def_name, query), 103 LexUserType::XrpcProcedure(proc) => self.generate_procedure(nsid, def_name, proc), 104 LexUserType::Token(_) => { 105 // Token types are marker types used in knownValues enums. 106 // We don't generate anything for them - the knownValues enum 107 // is the actual type that gets used. 108 Ok(quote! {}) 109 } 110 LexUserType::String(s) if s.known_values.is_some() => { 111 self.generate_known_values_enum(nsid, def_name, s) 112 } 113 LexUserType::String(s) => { 114 // Plain string type alias 115 let type_name = self.def_to_type_name(nsid, def_name); 116 let ident = syn::Ident::new(&type_name, proc_macro2::Span::call_site()); 117 let rust_type = self.string_to_rust_type(s); 118 let doc = self.generate_doc_comment(s.description.as_ref()); 119 Ok(quote! { 120 #doc 121 pub type #ident<'a> = #rust_type; 122 }) 123 } 124 LexUserType::Integer(i) if i.r#enum.is_some() => { 125 self.generate_integer_enum(nsid, def_name, i) 126 } 127 LexUserType::Array(array) => { 128 // Top-level array becomes type alias to Vec<ItemType> 129 let type_name = self.def_to_type_name(nsid, def_name); 130 let ident = syn::Ident::new(&type_name, proc_macro2::Span::call_site()); 131 let item_type = self.array_item_to_rust_type(nsid, &array.items)?; 132 let doc = self.generate_doc_comment(array.description.as_ref()); 133 let needs_lifetime = self.array_item_needs_lifetime(&array.items); 134 if needs_lifetime { 135 Ok(quote! { 136 #doc 137 pub type #ident<'a> = Vec<#item_type>; 138 }) 139 } else { 140 Ok(quote! { 141 #doc 142 pub type #ident = Vec<#item_type>; 143 }) 144 } 145 } 146 LexUserType::Boolean(_) 147 | LexUserType::Integer(_) 148 | LexUserType::Bytes(_) 149 | LexUserType::CidLink(_) 150 | LexUserType::Unknown(_) => { 151 // These are rarely top-level defs, but if they are, make type aliases 152 let type_name = self.def_to_type_name(nsid, def_name); 153 let ident = syn::Ident::new(&type_name, proc_macro2::Span::call_site()); 154 let (rust_type, needs_lifetime) = match def { 155 LexUserType::Boolean(_) => (quote! { bool }, false), 156 LexUserType::Integer(_) => (quote! { i64 }, false), 157 LexUserType::Bytes(_) => (quote! { bytes::Bytes }, false), 158 LexUserType::CidLink(_) => { 159 (quote! { jacquard_common::types::cid::CidLink<'a> }, true) 160 } 161 LexUserType::Unknown(_) => { 162 (quote! { jacquard_common::types::value::Data<'a> }, true) 163 } 164 _ => unreachable!(), 165 }; 166 if needs_lifetime { 167 Ok(quote! { 168 pub type #ident<'a> = #rust_type; 169 }) 170 } else { 171 Ok(quote! { 172 pub type #ident = #rust_type; 173 }) 174 } 175 } 176 LexUserType::Blob(_) => Err(CodegenError::unsupported( 177 format!("top-level def type {:?}", def), 178 nsid, 179 None::<String>, 180 )), 181 LexUserType::XrpcSubscription(sub) => self.generate_subscription(nsid, def_name, sub), 182 } 183 } 184 185 /// Generate a record type 186 fn generate_record( 187 &self, 188 nsid: &str, 189 def_name: &str, 190 record: &LexRecord<'static>, 191 ) -> Result<TokenStream> { 192 match &record.record { 193 crate::lexicon::LexRecordRecord::Object(obj) => { 194 let type_name = self.def_to_type_name(nsid, def_name); 195 let ident = syn::Ident::new(&type_name, proc_macro2::Span::call_site()); 196 197 // Generate main struct fields 198 let fields = self.generate_object_fields(nsid, &type_name, obj, false)?; 199 let doc = self.generate_doc_comment(record.description.as_ref()); 200 201 // Records always get a lifetime since they have the #[lexicon] attribute 202 // which adds extra_data: BTreeMap<..., Data<'a>> 203 let struct_def = quote! { 204 #doc 205 #[jacquard_derive::lexicon] 206 #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq, jacquard_derive::IntoStatic)] 207 #[serde(rename_all = "camelCase")] 208 pub struct #ident<'a> { 209 #fields 210 } 211 }; 212 213 // Generate union types and nested object types for this record 214 let mut unions = Vec::new(); 215 for (field_name, field_type) in &obj.properties { 216 match field_type { 217 LexObjectProperty::Union(union) => { 218 let union_name = 219 format!("{}Record{}", type_name, field_name.to_pascal_case()); 220 // Clone refs to avoid lifetime issues 221 let refs: Vec<_> = union.refs.iter().cloned().collect(); 222 let union_def = 223 self.generate_union(&union_name, &refs, None, union.closed)?; 224 unions.push(union_def); 225 } 226 LexObjectProperty::Object(nested_obj) => { 227 let object_name = 228 format!("{}Record{}", type_name, field_name.to_pascal_case()); 229 let obj_def = self.generate_object(nsid, &object_name, nested_obj)?; 230 unions.push(obj_def); 231 } 232 _ => {} 233 } 234 } 235 236 // Generate Collection trait impl 237 let collection_impl = quote! { 238 impl jacquard_common::types::collection::Collection for #ident<'_> { 239 const NSID: &'static str = #nsid; 240 } 241 }; 242 243 // Generate IntoStatic impl 244 // let field_names: Vec<&str> = obj.properties.keys().map(|k| k.as_str()).collect(); 245 // let into_static_impl = 246 // self.generate_into_static_for_struct(&type_name, &field_names, true, true); 247 248 Ok(quote! { 249 #struct_def 250 #(#unions)* 251 #collection_impl 252 //#into_static_impl 253 }) 254 } 255 } 256 } 257 258 /// Generate an object type 259 fn generate_object( 260 &self, 261 nsid: &str, 262 def_name: &str, 263 obj: &LexObject<'static>, 264 ) -> Result<TokenStream> { 265 let type_name = self.def_to_type_name(nsid, def_name); 266 let ident = syn::Ident::new(&type_name, proc_macro2::Span::call_site()); 267 268 let fields = self.generate_object_fields(nsid, &type_name, obj, false)?; 269 let doc = self.generate_doc_comment(obj.description.as_ref()); 270 271 // Objects always get a lifetime since they have the #[lexicon] attribute 272 // which adds extra_data: BTreeMap<..., Data<'a>> 273 let struct_def = quote! { 274 #doc 275 #[jacquard_derive::lexicon] 276 #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq, jacquard_derive::IntoStatic)] 277 #[serde(rename_all = "camelCase")] 278 pub struct #ident<'a> { 279 #fields 280 } 281 }; 282 283 // Generate union types and nested object types for this object 284 let mut unions = Vec::new(); 285 for (field_name, field_type) in &obj.properties { 286 match field_type { 287 LexObjectProperty::Union(union) => { 288 let union_name = format!("{}Record{}", type_name, field_name.to_pascal_case()); 289 let refs: Vec<_> = union.refs.iter().cloned().collect(); 290 let union_def = self.generate_union(&union_name, &refs, None, union.closed)?; 291 unions.push(union_def); 292 } 293 LexObjectProperty::Object(nested_obj) => { 294 let object_name = format!("{}Record{}", type_name, field_name.to_pascal_case()); 295 let obj_def = self.generate_object(nsid, &object_name, nested_obj)?; 296 unions.push(obj_def); 297 } 298 _ => {} 299 } 300 } 301 302 // Generate IntoStatic impl 303 // let field_names: Vec<&str> = obj.properties.keys().map(|k| k.as_str()).collect(); 304 // let into_static_impl = 305 // self.generate_into_static_for_struct(&type_name, &field_names, true, true); 306 307 Ok(quote! { 308 #struct_def 309 #(#unions)* 310 //#into_static_impl 311 }) 312 } 313 314 /// Generate fields for an object 315 fn generate_object_fields( 316 &self, 317 nsid: &str, 318 parent_type_name: &str, 319 obj: &LexObject<'static>, 320 is_builder: bool, 321 ) -> Result<TokenStream> { 322 let required = obj.required.as_ref().map(|r| r.as_slice()).unwrap_or(&[]); 323 324 let mut fields = Vec::new(); 325 for (field_name, field_type) in &obj.properties { 326 let is_required = required.contains(field_name); 327 let field_tokens = self.generate_field( 328 nsid, 329 parent_type_name, 330 field_name, 331 field_type, 332 is_required, 333 is_builder, 334 )?; 335 fields.push(field_tokens); 336 } 337 338 Ok(quote! { #(#fields)* }) 339 } 340 341 /// Generate a single field 342 fn generate_field( 343 &self, 344 nsid: &str, 345 parent_type_name: &str, 346 field_name: &str, 347 field_type: &LexObjectProperty<'static>, 348 is_required: bool, 349 is_builder: bool, 350 ) -> Result<TokenStream> { 351 if field_name.is_empty() { 352 eprintln!( 353 "Warning: Empty field name in lexicon '{}' type '{}', using 'unknown' as fallback", 354 nsid, parent_type_name 355 ); 356 } 357 let field_ident = make_ident(&field_name.to_snake_case()); 358 359 let rust_type = 360 self.property_to_rust_type(nsid, parent_type_name, field_name, field_type)?; 361 let needs_lifetime = self.property_needs_lifetime(field_type); 362 363 // Check if this is a CowStr field for builder(into) attribute 364 let is_cowstr = matches!(field_type, LexObjectProperty::String(s) if s.format.is_none()); 365 366 let rust_type = if is_required { 367 rust_type 368 } else { 369 quote! { std::option::Option<#rust_type> } 370 }; 371 372 // Extract description from field type 373 let description = match field_type { 374 LexObjectProperty::Ref(r) => r.description.as_ref(), 375 LexObjectProperty::Union(u) => u.description.as_ref(), 376 LexObjectProperty::Bytes(b) => b.description.as_ref(), 377 LexObjectProperty::CidLink(c) => c.description.as_ref(), 378 LexObjectProperty::Array(a) => a.description.as_ref(), 379 LexObjectProperty::Blob(b) => b.description.as_ref(), 380 LexObjectProperty::Object(o) => o.description.as_ref(), 381 LexObjectProperty::Boolean(b) => b.description.as_ref(), 382 LexObjectProperty::Integer(i) => i.description.as_ref(), 383 LexObjectProperty::String(s) => s.description.as_ref(), 384 LexObjectProperty::Unknown(u) => u.description.as_ref(), 385 }; 386 let doc = self.generate_doc_comment(description); 387 388 let mut attrs = Vec::new(); 389 390 if !is_required { 391 attrs.push(quote! { #[serde(skip_serializing_if = "std::option::Option::is_none")] }); 392 } 393 394 // Add serde(borrow) to all fields with lifetimes 395 if needs_lifetime { 396 attrs.push(quote! { #[serde(borrow)] }); 397 } 398 399 // Add builder(into) for CowStr fields to allow String, &str, etc., but only for builder structs 400 if is_builder && is_cowstr { 401 attrs.push(quote! { #[builder(into)] }); 402 } 403 404 Ok(quote! { 405 #doc 406 #(#attrs)* 407 pub #field_ident: #rust_type, 408 }) 409 } 410 411 /// Check if a property type needs a lifetime parameter 412 fn property_needs_lifetime(&self, prop: &LexObjectProperty<'static>) -> bool { 413 match prop { 414 LexObjectProperty::Boolean(_) | LexObjectProperty::Integer(_) => false, 415 LexObjectProperty::String(s) => self.string_needs_lifetime(s), 416 LexObjectProperty::Bytes(_) => false, // Bytes is owned 417 LexObjectProperty::CidLink(_) 418 | LexObjectProperty::Blob(_) 419 | LexObjectProperty::Unknown(_) => true, 420 LexObjectProperty::Array(array) => self.array_item_needs_lifetime(&array.items), 421 LexObjectProperty::Object(_) => true, // Nested objects have lifetimes 422 LexObjectProperty::Ref(ref_type) => { 423 // Check if the ref target actually needs a lifetime 424 self.ref_needs_lifetime(&ref_type.r#ref) 425 } 426 LexObjectProperty::Union(_) => true, // Unions generally have lifetimes 427 } 428 } 429 430 /// Check if an array item type needs a lifetime parameter 431 fn array_item_needs_lifetime(&self, item: &LexArrayItem) -> bool { 432 match item { 433 LexArrayItem::Boolean(_) | LexArrayItem::Integer(_) => false, 434 LexArrayItem::String(s) => self.string_needs_lifetime(s), 435 LexArrayItem::Bytes(_) => false, 436 LexArrayItem::CidLink(_) | LexArrayItem::Blob(_) | LexArrayItem::Unknown(_) => true, 437 LexArrayItem::Object(_) => true, // Nested objects have lifetimes 438 LexArrayItem::Ref(ref_type) => self.ref_needs_lifetime(&ref_type.r#ref), 439 LexArrayItem::Union(_) => true, 440 } 441 } 442 443 /// Check if a string type needs a lifetime parameter 444 fn string_needs_lifetime(&self, s: &LexString) -> bool { 445 match s.format { 446 Some(LexStringFormat::Datetime) 447 | Some(LexStringFormat::Language) 448 | Some(LexStringFormat::Tid) => false, 449 _ => true, // Most string types borrow 450 } 451 } 452 453 /// Check if a ref needs a lifetime parameter 454 fn ref_needs_lifetime(&self, ref_str: &str) -> bool { 455 // Try to resolve the ref 456 if let Some((_doc, def)) = self.corpus.resolve_ref(ref_str) { 457 self.def_needs_lifetime(def) 458 } else { 459 // If we can't resolve it, assume it needs a lifetime (safe default) 460 true 461 } 462 } 463 464 /// Check if a lexicon def needs a lifetime parameter 465 fn def_needs_lifetime(&self, def: &LexUserType<'static>) -> bool { 466 match def { 467 // Records and Objects always have lifetimes now since they get #[lexicon] attribute 468 LexUserType::Record(_) => true, 469 LexUserType::Object(_) => true, 470 LexUserType::Token(_) => false, 471 LexUserType::String(s) => { 472 // Check if it's a known values enum or a regular string 473 if s.known_values.is_some() { 474 // Known values enums have Other(CowStr<'a>) variant 475 true 476 } else { 477 self.string_needs_lifetime(s) 478 } 479 } 480 LexUserType::Integer(_) => false, 481 LexUserType::Boolean(_) => false, 482 LexUserType::Bytes(_) => false, 483 LexUserType::CidLink(_) | LexUserType::Blob(_) | LexUserType::Unknown(_) => true, 484 LexUserType::Array(array) => self.array_item_needs_lifetime(&array.items), 485 LexUserType::XrpcQuery(_) 486 | LexUserType::XrpcProcedure(_) 487 | LexUserType::XrpcSubscription(_) => { 488 // XRPC types generate multiple structs, not a single type we can reference 489 // Shouldn't be referenced directly 490 true 491 } 492 } 493 } 494 495 /// Check if xrpc params need a lifetime parameter 496 fn params_need_lifetime(&self, params: &crate::lexicon::LexXrpcParameters<'static>) -> bool { 497 params.properties.values().any(|prop| { 498 use crate::lexicon::LexXrpcParametersProperty; 499 match prop { 500 LexXrpcParametersProperty::Boolean(_) | LexXrpcParametersProperty::Integer(_) => { 501 false 502 } 503 LexXrpcParametersProperty::String(s) => self.string_needs_lifetime(s), 504 LexXrpcParametersProperty::Unknown(_) => true, 505 LexXrpcParametersProperty::Array(arr) => { 506 use crate::lexicon::LexPrimitiveArrayItem; 507 match &arr.items { 508 LexPrimitiveArrayItem::Boolean(_) | LexPrimitiveArrayItem::Integer(_) => { 509 false 510 } 511 LexPrimitiveArrayItem::String(s) => self.string_needs_lifetime(s), 512 LexPrimitiveArrayItem::Unknown(_) => true, 513 } 514 } 515 } 516 }) 517 } 518 519 /// Convert a property type to Rust type 520 fn property_to_rust_type( 521 &self, 522 nsid: &str, 523 parent_type_name: &str, 524 field_name: &str, 525 prop: &LexObjectProperty<'static>, 526 ) -> Result<TokenStream> { 527 match prop { 528 LexObjectProperty::Boolean(_) => Ok(quote! { bool }), 529 LexObjectProperty::Integer(_) => Ok(quote! { i64 }), 530 LexObjectProperty::String(s) => Ok(self.string_to_rust_type(s)), 531 LexObjectProperty::Bytes(_) => Ok(quote! { bytes::Bytes }), 532 LexObjectProperty::CidLink(_) => { 533 Ok(quote! { jacquard_common::types::cid::CidLink<'a> }) 534 } 535 LexObjectProperty::Blob(_) => Ok(quote! { jacquard_common::types::blob::Blob<'a> }), 536 LexObjectProperty::Unknown(_) => Ok(quote! { jacquard_common::types::value::Data<'a> }), 537 LexObjectProperty::Array(array) => { 538 let item_type = self.array_item_to_rust_type(nsid, &array.items)?; 539 Ok(quote! { Vec<#item_type> }) 540 } 541 LexObjectProperty::Object(_object) => { 542 // Generate unique nested object type name: StatusView + metadata -> StatusViewRecordMetadata 543 let object_name = 544 format!("{}Record{}", parent_type_name, field_name.to_pascal_case()); 545 let object_ident = syn::Ident::new(&object_name, proc_macro2::Span::call_site()); 546 Ok(quote! { #object_ident<'a> }) 547 } 548 LexObjectProperty::Ref(ref_type) => { 549 // Handle local refs (starting with #) by prepending the current NSID 550 let ref_str = if ref_type.r#ref.starts_with('#') { 551 format!("{}{}", nsid, ref_type.r#ref) 552 } else { 553 ref_type.r#ref.to_string() 554 }; 555 self.ref_to_rust_type(&ref_str) 556 } 557 LexObjectProperty::Union(_union) => { 558 // Generate unique union type name: StatusView + embed -> StatusViewRecordEmbed 559 let union_name = 560 format!("{}Record{}", parent_type_name, field_name.to_pascal_case()); 561 let union_ident = syn::Ident::new(&union_name, proc_macro2::Span::call_site()); 562 Ok(quote! { #union_ident<'a> }) 563 } 564 } 565 } 566 567 /// Convert array item to Rust type 568 fn array_item_to_rust_type(&self, nsid: &str, item: &LexArrayItem) -> Result<TokenStream> { 569 match item { 570 LexArrayItem::Boolean(_) => Ok(quote! { bool }), 571 LexArrayItem::Integer(_) => Ok(quote! { i64 }), 572 LexArrayItem::String(s) => Ok(self.string_to_rust_type(s)), 573 LexArrayItem::Bytes(_) => Ok(quote! { bytes::Bytes }), 574 LexArrayItem::CidLink(_) => Ok(quote! { jacquard_common::types::cid::CidLink<'a> }), 575 LexArrayItem::Blob(_) => Ok(quote! { jacquard_common::types::blob::Blob<'a> }), 576 LexArrayItem::Unknown(_) => Ok(quote! { jacquard_common::types::value::Data<'a> }), 577 LexArrayItem::Object(_) => { 578 // For inline objects in arrays, use Data since we can't generate a unique type name 579 Ok(quote! { jacquard_common::types::value::Data<'a> }) 580 } 581 LexArrayItem::Ref(ref_type) => { 582 // Handle local refs (starting with #) by prepending the current NSID 583 let ref_str = if ref_type.r#ref.starts_with('#') { 584 format!("{}{}", nsid, ref_type.r#ref) 585 } else { 586 ref_type.r#ref.to_string() 587 }; 588 self.ref_to_rust_type(&ref_str) 589 } 590 LexArrayItem::Union(_) => { 591 // For now, use Data 592 Ok(quote! { jacquard_common::types::value::Data<'a> }) 593 } 594 } 595 } 596 597 /// Convert string type to Rust type 598 fn string_to_rust_type(&self, s: &LexString) -> TokenStream { 599 match s.format { 600 Some(LexStringFormat::Datetime) => { 601 quote! { jacquard_common::types::string::Datetime } 602 } 603 Some(LexStringFormat::Did) => quote! { jacquard_common::types::string::Did<'a> }, 604 Some(LexStringFormat::Handle) => quote! { jacquard_common::types::string::Handle<'a> }, 605 Some(LexStringFormat::AtIdentifier) => { 606 quote! { jacquard_common::types::ident::AtIdentifier<'a> } 607 } 608 Some(LexStringFormat::Nsid) => quote! { jacquard_common::types::string::Nsid<'a> }, 609 Some(LexStringFormat::AtUri) => quote! { jacquard_common::types::string::AtUri<'a> }, 610 Some(LexStringFormat::Uri) => quote! { jacquard_common::types::string::Uri<'a> }, 611 Some(LexStringFormat::Cid) => quote! { jacquard_common::types::string::Cid<'a> }, 612 Some(LexStringFormat::Language) => { 613 quote! { jacquard_common::types::string::Language } 614 } 615 Some(LexStringFormat::Tid) => quote! { jacquard_common::types::string::Tid }, 616 Some(LexStringFormat::RecordKey) => { 617 quote! { jacquard_common::types::string::RecordKey<jacquard_common::types::string::Rkey<'a>> } 618 } 619 _ => quote! { jacquard_common::CowStr<'a> }, 620 } 621 } 622 623 /// Convert ref to Rust type path 624 fn ref_to_rust_type(&self, ref_str: &str) -> Result<TokenStream> { 625 // Parse NSID and fragment 626 let (ref_nsid, ref_def) = if let Some((nsid, fragment)) = ref_str.split_once('#') { 627 (nsid, fragment) 628 } else { 629 (ref_str, "main") 630 }; 631 632 // Check if ref exists 633 if !self.corpus.ref_exists(ref_str) { 634 // Fallback to Data 635 return Ok(quote! { jacquard_common::types::value::Data<'a> }); 636 } 637 638 // Convert NSID to module path 639 // com.atproto.repo.strongRef -> com_atproto::repo::strong_ref::StrongRef 640 // app.bsky.richtext.facet -> app_bsky::richtext::facet::Facet 641 // app.bsky.actor.defs#nux -> app_bsky::actor::Nux (defs go in parent module) 642 let parts: Vec<&str> = ref_nsid.split('.').collect(); 643 let last_segment = parts.last().unwrap(); 644 645 let type_name = self.def_to_type_name(ref_nsid, ref_def); 646 647 let path_str = if *last_segment == "defs" && parts.len() >= 3 { 648 // defs types go in parent module 649 let first_two = format!("{}_{}", sanitize_name(parts[0]), sanitize_name(parts[1])); 650 if parts.len() == 3 { 651 // com.atproto.defs -> com_atproto::TypeName 652 format!("{}::{}::{}", self.root_module, first_two, type_name) 653 } else { 654 // app.bsky.actor.defs -> app_bsky::actor::TypeName 655 let middle: Vec<_> = parts[2..parts.len() - 1] 656 .iter() 657 .copied() 658 .map(|s| sanitize_name(s)) 659 .collect(); 660 format!( 661 "{}::{}::{}::{}", 662 self.root_module, 663 first_two, 664 middle.join("::"), 665 type_name 666 ) 667 } 668 } else { 669 // Regular types go in their own module file 670 let (module_path, file_module) = if parts.len() >= 3 { 671 // Join first two segments with underscore 672 let first_two = format!("{}_{}", sanitize_name(parts[0]), sanitize_name(parts[1])); 673 let file_name = sanitize_name(last_segment).to_snake_case(); 674 675 if parts.len() > 3 { 676 // Middle segments form the module path 677 let middle: Vec<_> = parts[2..parts.len() - 1] 678 .iter() 679 .copied() 680 .map(|s| sanitize_name(s)) 681 .collect(); 682 let base_path = format!("{}::{}", first_two, middle.join("::")); 683 (base_path, file_name) 684 } else { 685 // Only 3 parts: com.atproto.label -> com_atproto, file: label 686 (first_two, file_name) 687 } 688 } else if parts.len() == 2 { 689 // e.g., "com.example" -> "com_example", file: example 690 let first = sanitize_name(parts[0]); 691 let file_name = sanitize_name(parts[1]).to_snake_case(); 692 (first, file_name) 693 } else { 694 (parts[0].to_string(), "main".to_string()) 695 }; 696 697 format!( 698 "{}::{}::{}::{}", 699 self.root_module, module_path, file_module, type_name 700 ) 701 }; 702 703 let path: syn::Path = syn::parse_str(&path_str).map_err(|e| CodegenError::Other { 704 message: format!("Failed to parse path: {} {}", path_str, e), 705 source: None, 706 })?; 707 708 // Only add lifetime if the target type needs it 709 if self.ref_needs_lifetime(ref_str) { 710 Ok(quote! { #path<'a> }) 711 } else { 712 Ok(quote! { #path }) 713 } 714 } 715 716 /// Generate query type 717 fn generate_query( 718 &self, 719 nsid: &str, 720 def_name: &str, 721 query: &LexXrpcQuery<'static>, 722 ) -> Result<TokenStream> { 723 let type_base = self.def_to_type_name(nsid, def_name); 724 let mut output = Vec::new(); 725 726 let params_has_lifetime = query 727 .parameters 728 .as_ref() 729 .map(|p| match p { 730 crate::lexicon::LexXrpcQueryParameter::Params(params) => { 731 self.params_need_lifetime(params) 732 } 733 }) 734 .unwrap_or(false); 735 let has_params = query.parameters.is_some(); 736 let has_output = query.output.is_some(); 737 let has_errors = query.errors.is_some(); 738 739 if let Some(params) = &query.parameters { 740 let params_struct = self.generate_params_struct(&type_base, params)?; 741 output.push(params_struct); 742 } 743 744 if let Some(body) = &query.output { 745 let output_struct = self.generate_output_struct(&type_base, body)?; 746 output.push(output_struct); 747 } 748 749 if let Some(errors) = &query.errors { 750 let error_enum = self.generate_error_enum(&type_base, errors)?; 751 output.push(error_enum); 752 } 753 754 // Generate XrpcRequest impl 755 let output_encoding = query 756 .output 757 .as_ref() 758 .map(|o| o.encoding.as_ref()) 759 .unwrap_or("application/json"); 760 let xrpc_impl = self.generate_xrpc_request_impl( 761 nsid, 762 &type_base, 763 quote! { jacquard_common::xrpc::XrpcMethod::Query }, 764 output_encoding, 765 has_params, 766 params_has_lifetime, 767 has_output, 768 has_errors, 769 false, // queries never have binary inputs 770 )?; 771 output.push(xrpc_impl); 772 773 Ok(quote! { 774 #(#output)* 775 }) 776 } 777 778 /// Generate procedure type 779 fn generate_procedure( 780 &self, 781 nsid: &str, 782 def_name: &str, 783 proc: &LexXrpcProcedure<'static>, 784 ) -> Result<TokenStream> { 785 let type_base = self.def_to_type_name(nsid, def_name); 786 let mut output = Vec::new(); 787 788 // Check if input is a binary body (no schema) 789 let is_binary_input = proc 790 .input 791 .as_ref() 792 .map(|i| i.schema.is_none()) 793 .unwrap_or(false); 794 795 // Input bodies with schemas have lifetimes (they get #[lexicon] attribute) 796 // Binary inputs don't have lifetimes 797 let params_has_lifetime = proc.input.is_some() && !is_binary_input; 798 let has_input = proc.input.is_some(); 799 let has_output = proc.output.is_some(); 800 let has_errors = proc.errors.is_some(); 801 802 if let Some(params) = &proc.parameters { 803 let params_struct = self.generate_params_struct_proc(&type_base, params)?; 804 output.push(params_struct); 805 } 806 807 if let Some(body) = &proc.input { 808 let input_struct = self.generate_input_struct(&type_base, body)?; 809 output.push(input_struct); 810 } 811 812 if let Some(body) = &proc.output { 813 let output_struct = self.generate_output_struct(&type_base, body)?; 814 output.push(output_struct); 815 } 816 817 if let Some(errors) = &proc.errors { 818 let error_enum = self.generate_error_enum(&type_base, errors)?; 819 output.push(error_enum); 820 } 821 822 // Generate XrpcRequest impl 823 let input_encoding = proc 824 .input 825 .as_ref() 826 .map(|i| i.encoding.as_ref()) 827 .unwrap_or("application/json"); 828 let output_encoding = proc 829 .output 830 .as_ref() 831 .map(|o| o.encoding.as_ref()) 832 .unwrap_or("application/json"); 833 let xrpc_impl = self.generate_xrpc_request_impl( 834 nsid, 835 &type_base, 836 quote! { jacquard_common::xrpc::XrpcMethod::Procedure(#input_encoding) }, 837 output_encoding, 838 has_input, 839 params_has_lifetime, 840 has_output, 841 has_errors, 842 is_binary_input, 843 )?; 844 output.push(xrpc_impl); 845 846 Ok(quote! { 847 #(#output)* 848 }) 849 } 850 851 fn generate_subscription( 852 &self, 853 nsid: &str, 854 def_name: &str, 855 sub: &LexXrpcSubscription<'static>, 856 ) -> Result<TokenStream> { 857 let type_base = self.def_to_type_name(nsid, def_name); 858 let mut output = Vec::new(); 859 860 if let Some(params) = &sub.parameters { 861 // Extract LexXrpcParameters from the enum 862 match params { 863 crate::lexicon::LexXrpcSubscriptionParameter::Params(params_inner) => { 864 let params_struct = 865 self.generate_params_struct_inner(&type_base, params_inner)?; 866 output.push(params_struct); 867 } 868 } 869 } 870 871 if let Some(message) = &sub.message { 872 if let Some(schema) = &message.schema { 873 let message_type = self.generate_subscription_message(&type_base, schema)?; 874 output.push(message_type); 875 } 876 } 877 878 if let Some(errors) = &sub.errors { 879 let error_enum = self.generate_error_enum(&type_base, errors)?; 880 output.push(error_enum); 881 } 882 883 Ok(quote! { 884 #(#output)* 885 }) 886 } 887 888 fn generate_subscription_message( 889 &self, 890 type_base: &str, 891 schema: &LexXrpcSubscriptionMessageSchema<'static>, 892 ) -> Result<TokenStream> { 893 use crate::lexicon::LexXrpcSubscriptionMessageSchema; 894 895 match schema { 896 LexXrpcSubscriptionMessageSchema::Union(union) => { 897 // Generate a union enum for the message 898 let enum_name = format!("{}Message", type_base); 899 let enum_ident = syn::Ident::new(&enum_name, proc_macro2::Span::call_site()); 900 901 let mut variants = Vec::new(); 902 for ref_str in &union.refs { 903 let ref_str_s = ref_str.as_ref(); 904 // Parse ref to get NSID and def name 905 let (ref_nsid, ref_def) = 906 if let Some((nsid, fragment)) = ref_str.split_once('#') { 907 (nsid, fragment) 908 } else { 909 (ref_str.as_ref(), "main") 910 }; 911 912 let variant_name = if ref_def == "main" { 913 ref_nsid.split('.').last().unwrap().to_pascal_case() 914 } else { 915 ref_def.to_pascal_case() 916 }; 917 let variant_ident = 918 syn::Ident::new(&variant_name, proc_macro2::Span::call_site()); 919 let type_path = self.ref_to_rust_type(ref_str)?; 920 921 variants.push(quote! { 922 #[serde(rename = #ref_str_s)] 923 #variant_ident(Box<#type_path>) 924 }); 925 } 926 927 let doc = self.generate_doc_comment(union.description.as_ref()); 928 929 // Generate IntoStatic impl for the enum 930 // let variant_info: Vec<(String, EnumVariantKind)> = union 931 // .refs 932 // .iter() 933 // .map(|ref_str| { 934 // let ref_def = if let Some((_, fragment)) = ref_str.split_once('#') { 935 // fragment 936 // } else { 937 // "main" 938 // }; 939 // let variant_name = if ref_def == "main" { 940 // ref_str.split('.').last().unwrap().to_pascal_case() 941 // } else { 942 // ref_def.to_pascal_case() 943 // }; 944 // (variant_name, EnumVariantKind::Tuple) 945 // }) 946 // .collect(); 947 // let into_static_impl = self.generate_into_static_for_enum( 948 // &enum_name, 949 // &variant_info, 950 // true, 951 // true, // open union 952 // ); 953 954 Ok(quote! { 955 #doc 956 #[jacquard_derive::open_union] 957 #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq, jacquard_derive::IntoStatic)] 958 #[serde(tag = "$type")] 959 #[serde(bound(deserialize = "'de: 'a"))] 960 pub enum #enum_ident<'a> { 961 #(#variants,)* 962 } 963 964 //#into_static_impl 965 }) 966 } 967 LexXrpcSubscriptionMessageSchema::Object(obj) => { 968 // Generate a struct for the message 969 let struct_name = format!("{}Message", type_base); 970 let struct_ident = syn::Ident::new(&struct_name, proc_macro2::Span::call_site()); 971 972 let fields = self.generate_object_fields("", &struct_name, obj, false)?; 973 let doc = self.generate_doc_comment(obj.description.as_ref()); 974 975 // Subscription message structs always get a lifetime since they have the #[lexicon] attribute 976 // which adds extra_data: BTreeMap<..., Data<'a>> 977 let struct_def = quote! { 978 #doc 979 #[jacquard_derive::lexicon] 980 #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq, jacquard_derive::IntoStatic)] 981 #[serde(rename_all = "camelCase")] 982 pub struct #struct_ident<'a> { 983 #fields 984 } 985 }; 986 987 // Generate union types for this message 988 let mut unions = Vec::new(); 989 for (field_name, field_type) in &obj.properties { 990 if let LexObjectProperty::Union(union) = field_type { 991 let union_name = 992 format!("{}Record{}", struct_name, field_name.to_pascal_case()); 993 let refs: Vec<_> = union.refs.iter().cloned().collect(); 994 let union_def = 995 self.generate_union(&union_name, &refs, None, union.closed)?; 996 unions.push(union_def); 997 } 998 } 999 1000 // Generate IntoStatic impl 1001 // let field_names: Vec<&str> = obj.properties.keys().map(|k| k.as_str()).collect(); 1002 // let into_static_impl = 1003 // self.generate_into_static_for_struct(&struct_name, &field_names, true, true); 1004 1005 Ok(quote! { 1006 #struct_def 1007 #(#unions)* 1008 //#into_static_impl 1009 }) 1010 } 1011 LexXrpcSubscriptionMessageSchema::Ref(ref_type) => { 1012 // Just a type alias to the referenced type 1013 // Refs generally have lifetimes, so always add <'a> 1014 let type_name = format!("{}Message", type_base); 1015 let ident = syn::Ident::new(&type_name, proc_macro2::Span::call_site()); 1016 let rust_type = self.ref_to_rust_type(&ref_type.r#ref)?; 1017 let doc = self.generate_doc_comment(ref_type.description.as_ref()); 1018 1019 Ok(quote! { 1020 #doc 1021 pub type #ident<'a> = #rust_type; 1022 }) 1023 } 1024 } 1025 } 1026 1027 /// Convert def name to Rust type name 1028 fn def_to_type_name(&self, nsid: &str, def_name: &str) -> String { 1029 if def_name == "main" { 1030 // Use last segment of NSID 1031 let base_name = nsid.split('.').last().unwrap().to_pascal_case(); 1032 1033 // Check if any other def would collide with this name 1034 if let Some(doc) = self.corpus.get(nsid) { 1035 let has_collision = doc.defs.keys().any(|other_def| { 1036 let other_def_str: &str = other_def.as_ref(); 1037 other_def_str != "main" && other_def_str.to_pascal_case() == base_name 1038 }); 1039 1040 if has_collision { 1041 return format!("{}Record", base_name); 1042 } 1043 } 1044 1045 base_name 1046 } else { 1047 def_name.to_pascal_case() 1048 } 1049 } 1050 1051 /// Convert NSID to file path relative to output directory 1052 /// 1053 /// - `app.bsky.feed.post` → `app_bsky/feed/post.rs` 1054 /// - `com.atproto.label.defs` → `com_atproto/label.rs` (defs go in parent) 1055 fn nsid_to_file_path(&self, nsid: &str) -> std::path::PathBuf { 1056 let parts: Vec<&str> = nsid.split('.').collect(); 1057 1058 if parts.len() < 2 { 1059 // Shouldn't happen with valid NSIDs, but handle gracefully 1060 return format!("{}.rs", sanitize_name(parts[0])).into(); 1061 } 1062 1063 let last = parts.last().unwrap(); 1064 1065 if *last == "defs" && parts.len() >= 3 { 1066 // defs go in parent module: com.atproto.label.defs → com_atproto/label.rs 1067 let first_two = format!("{}_{}", sanitize_name(parts[0]), sanitize_name(parts[1])); 1068 if parts.len() == 3 { 1069 // com.atproto.defs → com_atproto.rs 1070 format!("{}.rs", first_two).into() 1071 } else { 1072 // com.atproto.label.defs → com_atproto/label.rs 1073 let middle: Vec<&str> = parts[2..parts.len() - 1].iter().copied().collect(); 1074 let mut path = std::path::PathBuf::from(first_two); 1075 for segment in &middle[..middle.len() - 1] { 1076 path.push(sanitize_name(segment)); 1077 } 1078 path.push(format!("{}.rs", sanitize_name(middle.last().unwrap()))); 1079 path 1080 } 1081 } else { 1082 // Regular path: app.bsky.feed.post → app_bsky/feed/post.rs 1083 let first_two = format!("{}_{}", sanitize_name(parts[0]), sanitize_name(parts[1])); 1084 let mut path = std::path::PathBuf::from(first_two); 1085 1086 for segment in &parts[2..parts.len() - 1] { 1087 path.push(sanitize_name(segment)); 1088 } 1089 1090 path.push(format!("{}.rs", sanitize_name(&last.to_snake_case()))); 1091 path 1092 } 1093 } 1094 1095 /// Generate all code for the corpus, organized by file 1096 /// Returns a map of file paths to (tokens, optional NSID) 1097 pub fn generate_all( 1098 &self, 1099 ) -> Result<std::collections::BTreeMap<std::path::PathBuf, (TokenStream, Option<String>)>> { 1100 use std::collections::BTreeMap; 1101 1102 let mut file_contents: BTreeMap<std::path::PathBuf, Vec<TokenStream>> = BTreeMap::new(); 1103 let mut file_nsids: BTreeMap<std::path::PathBuf, String> = BTreeMap::new(); 1104 1105 // Generate code for all lexicons 1106 for (nsid, doc) in self.corpus.iter() { 1107 let file_path = self.nsid_to_file_path(nsid.as_ref()); 1108 1109 // Track which NSID this file is for 1110 file_nsids.insert(file_path.clone(), nsid.to_string()); 1111 1112 for (def_name, def) in &doc.defs { 1113 let tokens = self.generate_def(nsid.as_ref(), def_name.as_ref(), def)?; 1114 file_contents 1115 .entry(file_path.clone()) 1116 .or_default() 1117 .push(tokens); 1118 } 1119 } 1120 1121 // Combine all tokens for each file 1122 let mut result = BTreeMap::new(); 1123 for (path, tokens_vec) in file_contents { 1124 let nsid = file_nsids.get(&path).cloned(); 1125 result.insert(path, (quote! { #(#tokens_vec)* }, nsid)); 1126 } 1127 1128 Ok(result) 1129 } 1130 1131 /// Generate parent module files with pub mod declarations 1132 pub fn generate_module_tree( 1133 &self, 1134 file_map: &std::collections::BTreeMap<std::path::PathBuf, (TokenStream, Option<String>)>, 1135 defs_only: &std::collections::BTreeMap<std::path::PathBuf, (TokenStream, Option<String>)>, 1136 ) -> std::collections::BTreeMap<std::path::PathBuf, (TokenStream, Option<String>)> { 1137 use std::collections::{BTreeMap, BTreeSet}; 1138 1139 // Track what modules each directory needs to declare 1140 // Key: directory path, Value: set of module names (file stems) 1141 let mut dir_modules: BTreeMap<std::path::PathBuf, BTreeSet<String>> = BTreeMap::new(); 1142 1143 // Collect all parent directories that have files 1144 let mut all_dirs: BTreeSet<std::path::PathBuf> = BTreeSet::new(); 1145 for path in file_map.keys() { 1146 if let Some(parent_dir) = path.parent() { 1147 all_dirs.insert(parent_dir.to_path_buf()); 1148 } 1149 } 1150 1151 for path in file_map.keys() { 1152 if let Some(parent_dir) = path.parent() { 1153 if let Some(file_stem) = path.file_stem().and_then(|s| s.to_str()) { 1154 // Skip mod.rs and lib.rs - they're module files, not modules to declare 1155 if file_stem == "mod" || file_stem == "lib" { 1156 continue; 1157 } 1158 1159 // Always add the module declaration to parent 1160 dir_modules 1161 .entry(parent_dir.to_path_buf()) 1162 .or_default() 1163 .insert(file_stem.to_string()); 1164 } 1165 } 1166 } 1167 1168 // Generate module files 1169 let mut result = BTreeMap::new(); 1170 1171 for (dir, module_names) in dir_modules { 1172 let mod_file_path = if dir.components().count() == 0 { 1173 // Root directory -> lib.rs for library crates 1174 std::path::PathBuf::from("lib.rs") 1175 } else { 1176 // Subdirectory: app_bsky/feed -> app_bsky/feed.rs (Rust 2018 style) 1177 let dir_name = dir.file_name().and_then(|s| s.to_str()).unwrap_or("mod"); 1178 let sanitized_dir_name = sanitize_name(dir_name); 1179 let mut path = dir 1180 .parent() 1181 .unwrap_or_else(|| std::path::Path::new("")) 1182 .to_path_buf(); 1183 path.push(format!("{}.rs", sanitized_dir_name)); 1184 path 1185 }; 1186 1187 let is_root = dir.components().count() == 0; 1188 let mods: Vec<_> = module_names 1189 .iter() 1190 .map(|name| { 1191 let ident = make_ident(name); 1192 if is_root { 1193 // Top-level modules get feature gates 1194 quote! { 1195 #[cfg(feature = #name)] 1196 pub mod #ident; 1197 } 1198 } else { 1199 quote! { pub mod #ident; } 1200 } 1201 }) 1202 .collect(); 1203 1204 // If this file already exists in defs_only (e.g., from defs), merge the content 1205 let module_tokens = quote! { #(#mods)* }; 1206 if let Some((existing_tokens, nsid)) = defs_only.get(&mod_file_path) { 1207 // Put module declarations FIRST, then existing defs content 1208 result.insert( 1209 mod_file_path, 1210 (quote! { #module_tokens #existing_tokens }, nsid.clone()), 1211 ); 1212 } else { 1213 result.insert(mod_file_path, (module_tokens, None)); 1214 } 1215 } 1216 1217 result 1218 } 1219 1220 /// Write all generated code to disk 1221 pub fn write_to_disk(&self, output_dir: &std::path::Path) -> Result<()> { 1222 // Generate all code (defs only) 1223 let defs_files = self.generate_all()?; 1224 let mut all_files = defs_files.clone(); 1225 1226 // Generate module tree iteratively until no new files appear 1227 loop { 1228 let module_map = self.generate_module_tree(&all_files, &defs_files); 1229 let old_count = all_files.len(); 1230 1231 // Merge new module files 1232 for (path, tokens) in module_map { 1233 all_files.insert(path, tokens); 1234 } 1235 1236 if all_files.len() == old_count { 1237 // No new files added 1238 break; 1239 } 1240 } 1241 1242 // Write to disk 1243 for (path, (tokens, nsid)) in all_files { 1244 let full_path = output_dir.join(&path); 1245 1246 // Create parent directories 1247 if let Some(parent) = full_path.parent() { 1248 std::fs::create_dir_all(parent).map_err(|e| CodegenError::Other { 1249 message: format!("Failed to create directory {:?}: {}", parent, e), 1250 source: None, 1251 })?; 1252 } 1253 1254 // Format code 1255 let file: syn::File = syn::parse2(tokens.clone()).map_err(|e| CodegenError::Other { 1256 message: format!( 1257 "Failed to parse tokens for {:?}: {}\nTokens: {}", 1258 path, e, tokens 1259 ), 1260 source: None, 1261 })?; 1262 let mut formatted = prettyplease::unparse(&file); 1263 1264 // Add blank lines between top-level items for better readability 1265 let lines: Vec<&str> = formatted.lines().collect(); 1266 let mut result_lines = Vec::new(); 1267 1268 for (i, line) in lines.iter().enumerate() { 1269 result_lines.push(*line); 1270 1271 // Add blank line after closing braces that are at column 0 (top-level items) 1272 if *line == "}" && i + 1 < lines.len() && !lines[i + 1].is_empty() { 1273 result_lines.push(""); 1274 } 1275 1276 // Add blank line after last pub mod declaration before structs/enums 1277 if line.starts_with("pub mod ") && i + 1 < lines.len() { 1278 let next_line = lines[i + 1]; 1279 if !next_line.starts_with("pub mod ") && !next_line.is_empty() { 1280 result_lines.push(""); 1281 } 1282 } 1283 } 1284 1285 formatted = result_lines.join("\n"); 1286 1287 // Add header comment 1288 let header = if let Some(nsid) = nsid { 1289 format!( 1290 "// @generated by jacquard-lexicon. DO NOT EDIT.\n//\n// Lexicon: {}\n//\n// This file was automatically generated from Lexicon schemas.\n// Any manual changes will be overwritten on the next regeneration.\n\n", 1291 nsid 1292 ) 1293 } else { 1294 "// @generated by jacquard-lexicon. DO NOT EDIT.\n//\n// This file was automatically generated from Lexicon schemas.\n// Any manual changes will be overwritten on the next regeneration.\n\n".to_string() 1295 }; 1296 formatted = format!("{}{}", header, formatted); 1297 1298 // Write file 1299 std::fs::write(&full_path, formatted).map_err(|e| CodegenError::Other { 1300 message: format!("Failed to write file {:?}: {}", full_path, e), 1301 source: None, 1302 })?; 1303 } 1304 1305 Ok(()) 1306 } 1307 1308 /// Generate doc comment from description 1309 fn generate_doc_comment(&self, desc: Option<&jacquard_common::CowStr>) -> TokenStream { 1310 if let Some(desc) = desc { 1311 let doc = desc.as_ref(); 1312 quote! { #[doc = #doc] } 1313 } else { 1314 quote! {} 1315 } 1316 } 1317 1318 /// Generate params struct from XRPC query parameters 1319 fn generate_params_struct( 1320 &self, 1321 type_base: &str, 1322 params: &crate::lexicon::LexXrpcQueryParameter<'static>, 1323 ) -> Result<TokenStream> { 1324 use crate::lexicon::LexXrpcQueryParameter; 1325 match params { 1326 LexXrpcQueryParameter::Params(p) => self.generate_params_struct_inner(type_base, p), 1327 } 1328 } 1329 1330 /// Generate params struct from XRPC procedure parameters (query string params) 1331 fn generate_params_struct_proc( 1332 &self, 1333 type_base: &str, 1334 params: &crate::lexicon::LexXrpcProcedureParameter<'static>, 1335 ) -> Result<TokenStream> { 1336 use crate::lexicon::LexXrpcProcedureParameter; 1337 match params { 1338 // For procedures, query string params still get "Params" suffix since the main struct is the input 1339 LexXrpcProcedureParameter::Params(p) => { 1340 let struct_name = format!("{}Params", type_base); 1341 let ident = syn::Ident::new(&struct_name, proc_macro2::Span::call_site()); 1342 self.generate_params_struct_inner_with_name(&ident, p) 1343 } 1344 } 1345 } 1346 1347 /// Generate params struct inner (shared implementation) 1348 fn generate_params_struct_inner( 1349 &self, 1350 type_base: &str, 1351 p: &crate::lexicon::LexXrpcParameters<'static>, 1352 ) -> Result<TokenStream> { 1353 let ident = syn::Ident::new(type_base, proc_macro2::Span::call_site()); 1354 self.generate_params_struct_inner_with_name(&ident, p) 1355 } 1356 1357 /// Generate params struct with custom name 1358 fn generate_params_struct_inner_with_name( 1359 &self, 1360 ident: &syn::Ident, 1361 p: &crate::lexicon::LexXrpcParameters<'static>, 1362 ) -> Result<TokenStream> { 1363 let required = p.required.as_ref().map(|r| r.as_slice()).unwrap_or(&[]); 1364 let mut fields = Vec::new(); 1365 let mut default_fns = Vec::new(); 1366 1367 for (field_name, field_type) in &p.properties { 1368 let is_required = required.contains(field_name); 1369 let (field_tokens, default_fn) = 1370 self.generate_param_field_with_default("", field_name, field_type, is_required)?; 1371 fields.push(field_tokens); 1372 if let Some(fn_def) = default_fn { 1373 default_fns.push(fn_def); 1374 } 1375 } 1376 1377 let doc = self.generate_doc_comment(p.description.as_ref()); 1378 let needs_lifetime = self.params_need_lifetime(p); 1379 1380 let derives = quote! { 1381 #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq, bon::Builder, jacquard_derive::IntoStatic)] 1382 #[builder(start_fn = new)] 1383 }; 1384 1385 // Generate IntoStatic impl 1386 // let field_names: Vec<&str> = p.properties.keys().map(|k| k.as_str()).collect(); 1387 // let type_name = ident.to_string(); 1388 // let into_static_impl = 1389 // self.generate_into_static_for_struct(&type_name, &field_names, needs_lifetime, false); 1390 1391 if needs_lifetime { 1392 Ok(quote! { 1393 #(#default_fns)* 1394 1395 #doc 1396 #derives 1397 #[serde(rename_all = "camelCase")] 1398 pub struct #ident<'a> { 1399 #(#fields)* 1400 } 1401 1402 //#into_static_impl 1403 }) 1404 } else { 1405 Ok(quote! { 1406 #(#default_fns)* 1407 1408 #doc 1409 #derives 1410 #[serde(rename_all = "camelCase")] 1411 pub struct #ident { 1412 #(#fields)* 1413 } 1414 1415 //#into_static_impl 1416 }) 1417 } 1418 } 1419 1420 /// Generate param field with serde default if present 1421 /// Returns (field_tokens, optional_default_function) 1422 fn generate_param_field_with_default( 1423 &self, 1424 nsid: &str, 1425 field_name: &str, 1426 field_type: &crate::lexicon::LexXrpcParametersProperty<'static>, 1427 is_required: bool, 1428 ) -> Result<(TokenStream, Option<TokenStream>)> { 1429 use crate::lexicon::LexXrpcParametersProperty; 1430 use heck::ToSnakeCase; 1431 1432 // Get base field 1433 let base_field = self.generate_param_field(nsid, field_name, field_type, is_required)?; 1434 1435 // Generate default function and attribute for required fields with defaults 1436 // For optional fields, just add doc comments 1437 let (doc_comment, serde_attr, default_fn) = if is_required { 1438 match field_type { 1439 LexXrpcParametersProperty::Boolean(b) if b.default.is_some() => { 1440 let v = b.default.unwrap(); 1441 let fn_name = format!("_default_{}", field_name.to_snake_case()); 1442 let fn_ident = syn::Ident::new(&fn_name, proc_macro2::Span::call_site()); 1443 ( 1444 Some(format!("Defaults to `{}`", v)), 1445 Some(quote! { #[serde(default = #fn_name)] }), 1446 Some(quote! { 1447 fn #fn_ident() -> bool { #v } 1448 }), 1449 ) 1450 } 1451 LexXrpcParametersProperty::Integer(i) if i.default.is_some() => { 1452 let v = i.default.unwrap(); 1453 let fn_name = format!("_default_{}", field_name.to_snake_case()); 1454 let fn_ident = syn::Ident::new(&fn_name, proc_macro2::Span::call_site()); 1455 ( 1456 Some(format!("Defaults to `{}`", v)), 1457 Some(quote! { #[serde(default = #fn_name)] }), 1458 Some(quote! { 1459 fn #fn_ident() -> i64 { #v } 1460 }), 1461 ) 1462 } 1463 LexXrpcParametersProperty::String(s) if s.default.is_some() => { 1464 let v = s.default.as_ref().unwrap().as_ref(); 1465 let fn_name = format!("_default_{}", field_name.to_snake_case()); 1466 let fn_ident = syn::Ident::new(&fn_name, proc_macro2::Span::call_site()); 1467 ( 1468 Some(format!("Defaults to `\"{}\"`", v)), 1469 Some(quote! { #[serde(default = #fn_name)] }), 1470 Some(quote! { 1471 fn #fn_ident() -> jacquard_common::CowStr<'static> { 1472 jacquard_common::CowStr::from(#v) 1473 } 1474 }), 1475 ) 1476 } 1477 _ => (None, None, None), 1478 } 1479 } else { 1480 // Optional fields - just doc comments, no serde defaults 1481 let doc = match field_type { 1482 LexXrpcParametersProperty::Integer(i) => { 1483 let mut parts = Vec::new(); 1484 if let Some(def) = i.default { 1485 parts.push(format!("default: {}", def)); 1486 } 1487 if let Some(min) = i.minimum { 1488 parts.push(format!("min: {}", min)); 1489 } 1490 if let Some(max) = i.maximum { 1491 parts.push(format!("max: {}", max)); 1492 } 1493 if !parts.is_empty() { 1494 Some(format!("({})", parts.join(", "))) 1495 } else { 1496 None 1497 } 1498 } 1499 LexXrpcParametersProperty::String(s) => { 1500 let mut parts = Vec::new(); 1501 if let Some(def) = s.default.as_ref() { 1502 parts.push(format!("default: \"{}\"", def.as_ref())); 1503 } 1504 if let Some(min) = s.min_length { 1505 parts.push(format!("min length: {}", min)); 1506 } 1507 if let Some(max) = s.max_length { 1508 parts.push(format!("max length: {}", max)); 1509 } 1510 if !parts.is_empty() { 1511 Some(format!("({})", parts.join(", "))) 1512 } else { 1513 None 1514 } 1515 } 1516 LexXrpcParametersProperty::Boolean(b) => { 1517 b.default.map(|v| format!("(default: {})", v)) 1518 } 1519 _ => None, 1520 }; 1521 (doc, None, None) 1522 }; 1523 1524 let doc = doc_comment.as_ref().map(|d| quote! { #[doc = #d] }); 1525 let field_with_attrs = match (doc, serde_attr) { 1526 (Some(doc), Some(attr)) => quote! { 1527 #doc 1528 #attr 1529 #base_field 1530 }, 1531 (Some(doc), None) => quote! { 1532 #doc 1533 #base_field 1534 }, 1535 (None, Some(attr)) => quote! { 1536 #attr 1537 #base_field 1538 }, 1539 (None, None) => base_field, 1540 }; 1541 1542 Ok((field_with_attrs, default_fn)) 1543 } 1544 1545 /// Generate input struct from XRPC body 1546 fn generate_input_struct( 1547 &self, 1548 type_base: &str, 1549 body: &LexXrpcBody<'static>, 1550 ) -> Result<TokenStream> { 1551 let ident = syn::Ident::new(type_base, proc_macro2::Span::call_site()); 1552 1553 // Check if this is a binary body (no schema, just raw bytes) 1554 let is_binary_body = body.schema.is_none(); 1555 1556 let fields = if let Some(schema) = &body.schema { 1557 self.generate_body_fields("", type_base, schema, true)? 1558 } else { 1559 // Binary body: just a bytes field 1560 quote! { 1561 pub body: bytes::Bytes, 1562 } 1563 }; 1564 1565 let doc = self.generate_doc_comment(body.description.as_ref()); 1566 1567 // Binary bodies don't need #[lexicon] attribute or lifetime 1568 let struct_def = if is_binary_body { 1569 quote! { 1570 #doc 1571 #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq, bon::Builder, jacquard_derive::IntoStatic)] 1572 #[builder(start_fn = new)] 1573 #[serde(rename_all = "camelCase")] 1574 pub struct #ident { 1575 #fields 1576 } 1577 } 1578 } else { 1579 // Input structs with schemas: manually add extra_data field with #[builder(default)] 1580 // for bon compatibility. The #[lexicon] macro will see it exists and skip adding it. 1581 quote! { 1582 #doc 1583 #[jacquard_derive::lexicon] 1584 #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq, bon::Builder, jacquard_derive::IntoStatic)] 1585 #[serde(rename_all = "camelCase")] 1586 #[builder(start_fn = new)] 1587 pub struct #ident<'a> { 1588 #fields 1589 #[serde(flatten)] 1590 #[serde(borrow)] 1591 #[builder(default)] 1592 pub extra_data: ::std::collections::BTreeMap< 1593 ::jacquard_common::smol_str::SmolStr, 1594 ::jacquard_common::types::value::Data<'a> 1595 >, 1596 } 1597 } 1598 }; 1599 1600 // Generate union types if schema is an Object 1601 let mut unions = Vec::new(); 1602 if let Some(crate::lexicon::LexXrpcBodySchema::Object(obj)) = &body.schema { 1603 for (field_name, field_type) in &obj.properties { 1604 if let LexObjectProperty::Union(union) = field_type { 1605 let union_name = format!("{}Record{}", type_base, field_name.to_pascal_case()); 1606 let refs: Vec<_> = union.refs.iter().cloned().collect(); 1607 let union_def = self.generate_union(&union_name, &refs, None, union.closed)?; 1608 unions.push(union_def); 1609 } 1610 } 1611 } 1612 1613 // Generate IntoStatic impl 1614 // let into_static_impl = if is_binary_body { 1615 // // Binary bodies: simple clone of the Bytes field 1616 // quote! { 1617 // impl jacquard_common::IntoStatic for #ident { 1618 // type Output = #ident; 1619 // fn into_static(self) -> Self::Output { 1620 // self 1621 // } 1622 // } 1623 // } 1624 // } else { 1625 // let field_names: Vec<&str> = match &body.schema { 1626 // Some(crate::lexicon::LexXrpcBodySchema::Object(obj)) => { 1627 // obj.properties.keys().map(|k| k.as_str()).collect() 1628 // } 1629 // Some(_) => { 1630 // // For Ref or Union schemas, there's just a single flattened field 1631 // vec!["value"] 1632 // } 1633 // None => { 1634 // // No schema means no fields, just extra_data 1635 // vec![] 1636 // } 1637 // }; 1638 // self.generate_into_static_for_struct(type_base, &field_names, true, true) 1639 // }; 1640 1641 Ok(quote! { 1642 #struct_def 1643 #(#unions)* 1644 //#into_static_impl 1645 }) 1646 } 1647 1648 /// Generate output struct from XRPC body 1649 fn generate_output_struct( 1650 &self, 1651 type_base: &str, 1652 body: &LexXrpcBody<'static>, 1653 ) -> Result<TokenStream> { 1654 let struct_name = format!("{}Output", type_base); 1655 let ident = syn::Ident::new(&struct_name, proc_macro2::Span::call_site()); 1656 1657 let fields = if let Some(schema) = &body.schema { 1658 self.generate_body_fields("", &struct_name, schema, false)? 1659 } else { 1660 quote! {} 1661 }; 1662 1663 let doc = self.generate_doc_comment(body.description.as_ref()); 1664 1665 // Output structs always get a lifetime since they have the #[lexicon] attribute 1666 // which adds extra_data: BTreeMap<..., Data<'a>> 1667 let struct_def = quote! { 1668 #doc 1669 #[jacquard_derive::lexicon] 1670 #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq, jacquard_derive::IntoStatic)] 1671 #[serde(rename_all = "camelCase")] 1672 pub struct #ident<'a> { 1673 #fields 1674 } 1675 }; 1676 1677 // Generate union types if schema is an Object 1678 let mut unions = Vec::new(); 1679 if let Some(crate::lexicon::LexXrpcBodySchema::Object(obj)) = &body.schema { 1680 for (field_name, field_type) in &obj.properties { 1681 if let LexObjectProperty::Union(union) = field_type { 1682 let union_name = 1683 format!("{}Record{}", struct_name, field_name.to_pascal_case()); 1684 let refs: Vec<_> = union.refs.iter().cloned().collect(); 1685 let union_def = self.generate_union(&union_name, &refs, None, union.closed)?; 1686 unions.push(union_def); 1687 } 1688 } 1689 } 1690 1691 // Generate IntoStatic impl 1692 // let field_names: Vec<&str> = match &body.schema { 1693 // Some(crate::lexicon::LexXrpcBodySchema::Object(obj)) => { 1694 // obj.properties.keys().map(|k| k.as_str()).collect() 1695 // } 1696 // Some(_) => { 1697 // // For Ref or Union schemas, there's just a single flattened field 1698 // vec!["value"] 1699 // } 1700 // None => { 1701 // // No schema means no fields, just extra_data 1702 // vec![] 1703 // } 1704 // }; 1705 // let into_static_impl = 1706 // self.generate_into_static_for_struct(&struct_name, &field_names, true, true); 1707 1708 Ok(quote! { 1709 #struct_def 1710 #(#unions)* 1711 //#into_static_impl 1712 }) 1713 } 1714 1715 /// Generate fields from XRPC body schema 1716 fn generate_body_fields( 1717 &self, 1718 nsid: &str, 1719 parent_type_name: &str, 1720 schema: &LexXrpcBodySchema<'static>, 1721 is_builder: bool, 1722 ) -> Result<TokenStream> { 1723 use crate::lexicon::LexXrpcBodySchema; 1724 1725 match schema { 1726 LexXrpcBodySchema::Object(obj) => { 1727 self.generate_object_fields(nsid, parent_type_name, obj, is_builder) 1728 } 1729 LexXrpcBodySchema::Ref(ref_type) => { 1730 let rust_type = self.ref_to_rust_type(&ref_type.r#ref)?; 1731 Ok(quote! { 1732 #[serde(flatten)] 1733 #[serde(borrow)] 1734 pub value: #rust_type, 1735 }) 1736 } 1737 LexXrpcBodySchema::Union(_union) => { 1738 let rust_type = quote! { jacquard_common::types::value::Data<'a> }; 1739 Ok(quote! { 1740 #[serde(flatten)] 1741 #[serde(borrow)] 1742 pub value: #rust_type, 1743 }) 1744 } 1745 } 1746 } 1747 1748 /// Generate a field for XRPC parameters 1749 fn generate_param_field( 1750 &self, 1751 _nsid: &str, 1752 field_name: &str, 1753 field_type: &crate::lexicon::LexXrpcParametersProperty<'static>, 1754 is_required: bool, 1755 ) -> Result<TokenStream> { 1756 use crate::lexicon::LexXrpcParametersProperty; 1757 1758 let field_ident = make_ident(&field_name.to_snake_case()); 1759 1760 let (rust_type, needs_lifetime, is_cowstr) = match field_type { 1761 LexXrpcParametersProperty::Boolean(_) => (quote! { bool }, false, false), 1762 LexXrpcParametersProperty::Integer(_) => (quote! { i64 }, false, false), 1763 LexXrpcParametersProperty::String(s) => { 1764 let is_cowstr = s.format.is_none(); // CowStr for plain strings 1765 ( 1766 self.string_to_rust_type(s), 1767 self.string_needs_lifetime(s), 1768 is_cowstr, 1769 ) 1770 } 1771 LexXrpcParametersProperty::Unknown(_) => ( 1772 quote! { jacquard_common::types::value::Data<'a> }, 1773 true, 1774 false, 1775 ), 1776 LexXrpcParametersProperty::Array(arr) => { 1777 let needs_lifetime = match &arr.items { 1778 crate::lexicon::LexPrimitiveArrayItem::Boolean(_) 1779 | crate::lexicon::LexPrimitiveArrayItem::Integer(_) => false, 1780 crate::lexicon::LexPrimitiveArrayItem::String(s) => { 1781 self.string_needs_lifetime(s) 1782 } 1783 crate::lexicon::LexPrimitiveArrayItem::Unknown(_) => true, 1784 }; 1785 let item_type = match &arr.items { 1786 crate::lexicon::LexPrimitiveArrayItem::Boolean(_) => quote! { bool }, 1787 crate::lexicon::LexPrimitiveArrayItem::Integer(_) => quote! { i64 }, 1788 crate::lexicon::LexPrimitiveArrayItem::String(s) => self.string_to_rust_type(s), 1789 crate::lexicon::LexPrimitiveArrayItem::Unknown(_) => { 1790 quote! { jacquard_common::types::value::Data<'a> } 1791 } 1792 }; 1793 (quote! { Vec<#item_type> }, needs_lifetime, false) 1794 } 1795 }; 1796 1797 let rust_type = if is_required { 1798 rust_type 1799 } else { 1800 quote! { std::option::Option<#rust_type> } 1801 }; 1802 1803 let mut attrs = Vec::new(); 1804 1805 if !is_required { 1806 attrs.push(quote! { #[serde(skip_serializing_if = "std::option::Option::is_none")] }); 1807 } 1808 1809 // Add serde(borrow) to all fields with lifetimes 1810 if needs_lifetime { 1811 attrs.push(quote! { #[serde(borrow)] }); 1812 } 1813 1814 // Add builder(into) for CowStr fields to allow String, &str, etc. 1815 if is_cowstr { 1816 attrs.push(quote! { #[builder(into)] }); 1817 } 1818 1819 Ok(quote! { 1820 #(#attrs)* 1821 pub #field_ident: #rust_type, 1822 }) 1823 } 1824 1825 /// Generate error enum from XRPC errors 1826 fn generate_error_enum( 1827 &self, 1828 type_base: &str, 1829 errors: &[LexXrpcError<'static>], 1830 ) -> Result<TokenStream> { 1831 let enum_name = format!("{}Error", type_base); 1832 let ident = syn::Ident::new(&enum_name, proc_macro2::Span::call_site()); 1833 1834 let mut variants = Vec::new(); 1835 let mut display_arms = Vec::new(); 1836 1837 for error in errors { 1838 let variant_name = error.name.to_pascal_case(); 1839 let variant_ident = syn::Ident::new(&variant_name, proc_macro2::Span::call_site()); 1840 1841 let error_name = error.name.as_ref(); 1842 let doc = self.generate_doc_comment(error.description.as_ref()); 1843 1844 variants.push(quote! { 1845 #doc 1846 #[serde(rename = #error_name)] 1847 #variant_ident(std::option::Option<String>) 1848 }); 1849 1850 display_arms.push(quote! { 1851 Self::#variant_ident(msg) => { 1852 write!(f, #error_name)?; 1853 if let Some(msg) = msg { 1854 write!(f, ": {}", msg)?; 1855 } 1856 Ok(()) 1857 } 1858 }); 1859 } 1860 1861 // Generate IntoStatic impl 1862 let variant_info: Vec<(String, EnumVariantKind)> = errors 1863 .iter() 1864 .map(|e| (e.name.to_pascal_case(), EnumVariantKind::Tuple)) 1865 .collect(); 1866 let into_static_impl = 1867 self.generate_into_static_for_enum(&enum_name, &variant_info, true, true); 1868 1869 Ok(quote! { 1870 #[jacquard_derive::open_union] 1871 #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)] 1872 #[serde(tag = "error", content = "message")] 1873 #[serde(bound(deserialize = "'de: 'a"))] 1874 pub enum #ident<'a> { 1875 #(#variants,)* 1876 } 1877 1878 impl std::fmt::Display for #ident<'_> { 1879 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 1880 match self { 1881 #(#display_arms)* 1882 Self::Unknown(err) => write!(f, "Unknown error: {:?}", err), 1883 } 1884 } 1885 } 1886 1887 #into_static_impl 1888 }) 1889 } 1890 1891 /// Generate enum for string with known values 1892 fn generate_known_values_enum( 1893 &self, 1894 nsid: &str, 1895 def_name: &str, 1896 string: &LexString<'static>, 1897 ) -> Result<TokenStream> { 1898 let type_name = self.def_to_type_name(nsid, def_name); 1899 let ident = syn::Ident::new(&type_name, proc_macro2::Span::call_site()); 1900 1901 let known_values = string.known_values.as_ref().unwrap(); 1902 let mut variants = Vec::new(); 1903 let mut from_str_arms = Vec::new(); 1904 let mut as_str_arms = Vec::new(); 1905 1906 for value in known_values { 1907 // Convert value to valid Rust identifier 1908 let value_str = value.as_ref(); 1909 let variant_name = value_to_variant_name(value_str); 1910 let variant_ident = syn::Ident::new(&variant_name, proc_macro2::Span::call_site()); 1911 1912 variants.push(quote! { 1913 #variant_ident 1914 }); 1915 1916 from_str_arms.push(quote! { 1917 #value_str => Self::#variant_ident 1918 }); 1919 1920 as_str_arms.push(quote! { 1921 Self::#variant_ident => #value_str 1922 }); 1923 } 1924 1925 let doc = self.generate_doc_comment(string.description.as_ref()); 1926 1927 // Generate IntoStatic impl 1928 let variant_info: Vec<(String, EnumVariantKind)> = known_values 1929 .iter() 1930 .map(|value| { 1931 let variant_name = value_to_variant_name(value.as_ref()); 1932 (variant_name, EnumVariantKind::Unit) 1933 }) 1934 .chain(std::iter::once(( 1935 "Other".to_string(), 1936 EnumVariantKind::Tuple, 1937 ))) 1938 .collect(); 1939 let into_static_impl = 1940 self.generate_into_static_for_enum(&type_name, &variant_info, true, false); 1941 1942 Ok(quote! { 1943 #doc 1944 #[derive(Debug, Clone, PartialEq, Eq, Hash)] 1945 pub enum #ident<'a> { 1946 #(#variants,)* 1947 Other(jacquard_common::CowStr<'a>), 1948 } 1949 1950 impl<'a> #ident<'a> { 1951 pub fn as_str(&self) -> &str { 1952 match self { 1953 #(#as_str_arms,)* 1954 Self::Other(s) => s.as_ref(), 1955 } 1956 } 1957 } 1958 1959 impl<'a> From<&'a str> for #ident<'a> { 1960 fn from(s: &'a str) -> Self { 1961 match s { 1962 #(#from_str_arms,)* 1963 _ => Self::Other(jacquard_common::CowStr::from(s)), 1964 } 1965 } 1966 } 1967 1968 impl<'a> From<String> for #ident<'a> { 1969 fn from(s: String) -> Self { 1970 match s.as_str() { 1971 #(#from_str_arms,)* 1972 _ => Self::Other(jacquard_common::CowStr::from(s)), 1973 } 1974 } 1975 } 1976 1977 impl<'a> AsRef<str> for #ident<'a> { 1978 fn as_ref(&self) -> &str { 1979 self.as_str() 1980 } 1981 } 1982 1983 impl<'a> serde::Serialize for #ident<'a> { 1984 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 1985 where 1986 S: serde::Serializer, 1987 { 1988 serializer.serialize_str(self.as_str()) 1989 } 1990 } 1991 1992 impl<'de, 'a> serde::Deserialize<'de> for #ident<'a> 1993 where 1994 'de: 'a, 1995 { 1996 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 1997 where 1998 D: serde::Deserializer<'de>, 1999 { 2000 let s = <&'de str>::deserialize(deserializer)?; 2001 Ok(Self::from(s)) 2002 } 2003 } 2004 2005 #into_static_impl 2006 }) 2007 } 2008 2009 /// Generate enum for integer with enum values 2010 fn generate_integer_enum( 2011 &self, 2012 nsid: &str, 2013 def_name: &str, 2014 integer: &LexInteger<'static>, 2015 ) -> Result<TokenStream> { 2016 let type_name = self.def_to_type_name(nsid, def_name); 2017 let ident = syn::Ident::new(&type_name, proc_macro2::Span::call_site()); 2018 2019 let enum_values = integer.r#enum.as_ref().unwrap(); 2020 let mut variants = Vec::new(); 2021 let mut from_i64_arms = Vec::new(); 2022 let mut to_i64_arms = Vec::new(); 2023 2024 for value in enum_values { 2025 let variant_name = format!("Value{}", value.abs()); 2026 let variant_ident = syn::Ident::new(&variant_name, proc_macro2::Span::call_site()); 2027 2028 variants.push(quote! { 2029 #[serde(rename = #value)] 2030 #variant_ident 2031 }); 2032 2033 from_i64_arms.push(quote! { 2034 #value => Self::#variant_ident 2035 }); 2036 2037 to_i64_arms.push(quote! { 2038 Self::#variant_ident => #value 2039 }); 2040 } 2041 2042 let doc = self.generate_doc_comment(integer.description.as_ref()); 2043 2044 Ok(quote! { 2045 #doc 2046 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 2047 pub enum #ident { 2048 #(#variants,)* 2049 #[serde(untagged)] 2050 Other(i64), 2051 } 2052 2053 impl #ident { 2054 pub fn as_i64(&self) -> i64 { 2055 match self { 2056 #(#to_i64_arms,)* 2057 Self::Other(n) => *n, 2058 } 2059 } 2060 } 2061 2062 impl From<i64> for #ident { 2063 fn from(n: i64) -> Self { 2064 match n { 2065 #(#from_i64_arms,)* 2066 _ => Self::Other(n), 2067 } 2068 } 2069 } 2070 2071 impl serde::Serialize for #ident { 2072 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 2073 where 2074 S: serde::Serializer, 2075 { 2076 serializer.serialize_i64(self.as_i64()) 2077 } 2078 } 2079 2080 impl<'de> serde::Deserialize<'de> for #ident { 2081 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 2082 where 2083 D: serde::Deserializer<'de>, 2084 { 2085 let n = i64::deserialize(deserializer)?; 2086 Ok(Self::from(n)) 2087 } 2088 } 2089 }) 2090 } 2091 2092 /// Generate XrpcRequest trait impl for a query or procedure 2093 fn generate_xrpc_request_impl( 2094 &self, 2095 nsid: &str, 2096 type_base: &str, 2097 method: TokenStream, 2098 output_encoding: &str, 2099 has_params: bool, 2100 params_has_lifetime: bool, 2101 has_output: bool, 2102 has_errors: bool, 2103 is_binary_input: bool, 2104 ) -> Result<TokenStream> { 2105 let output_type = if has_output { 2106 let output_ident = syn::Ident::new( 2107 &format!("{}Output", type_base), 2108 proc_macro2::Span::call_site(), 2109 ); 2110 quote! { #output_ident<'de> } 2111 } else { 2112 quote! { () } 2113 }; 2114 2115 let error_type = if has_errors { 2116 let error_ident = syn::Ident::new( 2117 &format!("{}Error", type_base), 2118 proc_macro2::Span::call_site(), 2119 ); 2120 quote! { #error_ident<'de> } 2121 } else { 2122 quote! { jacquard_common::xrpc::GenericError<'de> } 2123 }; 2124 2125 // Generate the response type that implements XrpcResp 2126 let response_ident = syn::Ident::new( 2127 &format!("{}Response", type_base), 2128 proc_macro2::Span::call_site(), 2129 ); 2130 2131 // Generate the endpoint type that implements XrpcEndpoint 2132 let endpoint_ident = syn::Ident::new( 2133 &format!("{}Request", type_base), 2134 proc_macro2::Span::call_site(), 2135 ); 2136 2137 let response_type = quote! { 2138 #[doc = "Response type for "] 2139 #[doc = #nsid] 2140 pub struct #response_ident; 2141 2142 impl jacquard_common::xrpc::XrpcResp for #response_ident { 2143 const NSID: &'static str = #nsid; 2144 const ENCODING: &'static str = #output_encoding; 2145 type Output<'de> = #output_type; 2146 type Err<'de> = #error_type; 2147 } 2148 }; 2149 2150 // Generate encode_body() method for binary inputs 2151 let encode_body_method = if is_binary_input { 2152 quote! { 2153 fn encode_body(&self) -> Result<Vec<u8>, jacquard_common::xrpc::EncodeError> { 2154 Ok(self.body.to_vec()) 2155 } 2156 } 2157 } else { 2158 quote! {} 2159 }; 2160 2161 // Generate decode_body() method for binary inputs 2162 let decode_body_method = if is_binary_input { 2163 quote! { 2164 fn decode_body( 2165 body: &'de [u8], 2166 ) -> Result<Box<Self>, jacquard_common::error::DecodeError> { 2167 Ok(Box::new(Self { 2168 body: bytes::Bytes::copy_from_slice(body), 2169 })) 2170 } 2171 } 2172 } else { 2173 quote! {} 2174 }; 2175 2176 let endpoint_path = format!("/xrpc/{}", nsid); 2177 2178 if has_params { 2179 // Implement on the params/input struct itself 2180 let request_ident = syn::Ident::new(type_base, proc_macro2::Span::call_site()); 2181 let impl_target = if params_has_lifetime { 2182 quote! { #request_ident<'de> } 2183 } else { 2184 quote! { #request_ident } 2185 }; 2186 2187 Ok(quote! { 2188 #response_type 2189 2190 impl<'de> jacquard_common::xrpc::XrpcRequest<'de> for #impl_target { 2191 const NSID: &'static str = #nsid; 2192 const METHOD: jacquard_common::xrpc::XrpcMethod = #method; 2193 2194 type Response = #response_ident; 2195 2196 #encode_body_method 2197 #decode_body_method 2198 } 2199 2200 #[doc = "Endpoint type for "] 2201 #[doc = #nsid] 2202 pub struct #endpoint_ident; 2203 2204 impl jacquard_common::xrpc::XrpcEndpoint for #endpoint_ident { 2205 const PATH: &'static str = #endpoint_path; 2206 const METHOD: jacquard_common::xrpc::XrpcMethod = #method; 2207 2208 type Request<'de> = #impl_target; 2209 type Response = #response_ident; 2210 } 2211 }) 2212 } else { 2213 // No params - generate a marker struct 2214 let request_ident = syn::Ident::new(type_base, proc_macro2::Span::call_site()); 2215 2216 Ok(quote! { 2217 /// XRPC request marker type 2218 #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, jacquard_derive::IntoStatic)] 2219 pub struct #request_ident; 2220 2221 #response_type 2222 2223 impl<'de> jacquard_common::xrpc::XrpcRequest<'de> for #request_ident { 2224 const NSID: &'static str = #nsid; 2225 const METHOD: jacquard_common::xrpc::XrpcMethod = #method; 2226 2227 type Response = #response_ident; 2228 } 2229 2230 #[doc = "Endpoint type for "] 2231 #[doc = #nsid] 2232 pub struct #endpoint_ident; 2233 2234 impl jacquard_common::xrpc::XrpcEndpoint for #endpoint_ident { 2235 const PATH: &'static str = #endpoint_path; 2236 const METHOD: jacquard_common::xrpc::XrpcMethod = #method; 2237 2238 type Request<'de> = #request_ident; 2239 type Response = #response_ident; 2240 } 2241 }) 2242 } 2243 } 2244 2245 /// Generate a union enum 2246 pub fn generate_union( 2247 &self, 2248 union_name: &str, 2249 refs: &[jacquard_common::CowStr<'static>], 2250 description: Option<&str>, 2251 closed: Option<bool>, 2252 ) -> Result<TokenStream> { 2253 let enum_ident = syn::Ident::new(union_name, proc_macro2::Span::call_site()); 2254 2255 let mut variants = Vec::new(); 2256 for ref_str in refs { 2257 // Parse ref to get NSID and def name 2258 let (ref_nsid, ref_def) = if let Some((nsid, fragment)) = ref_str.split_once('#') { 2259 (nsid, fragment) 2260 } else { 2261 (ref_str.as_ref(), "main") 2262 }; 2263 2264 // Skip unknown refs - they'll be handled by Unknown variant 2265 if !self.corpus.ref_exists(ref_str.as_ref()) { 2266 continue; 2267 } 2268 2269 // Generate variant name from def name (or last NSID segment if main) 2270 // For non-main refs, include the last NSID segment to avoid collisions 2271 // e.g. app.bsky.embed.images#view -> ImagesView 2272 // app.bsky.embed.video#view -> VideoView 2273 let variant_name = if ref_def == "main" { 2274 ref_nsid.split('.').last().unwrap().to_pascal_case() 2275 } else { 2276 let last_segment = ref_nsid.split('.').last().unwrap().to_pascal_case(); 2277 format!("{}{}", last_segment, ref_def.to_pascal_case()) 2278 }; 2279 let variant_ident = syn::Ident::new(&variant_name, proc_macro2::Span::call_site()); 2280 2281 // Get the Rust type for this ref 2282 let rust_type = self.ref_to_rust_type(ref_str.as_ref())?; 2283 2284 // Add serde rename for the full NSID 2285 let ref_str_literal = ref_str.as_ref(); 2286 variants.push(quote! { 2287 #[serde(rename = #ref_str_literal)] 2288 #variant_ident(Box<#rust_type>) 2289 }); 2290 } 2291 2292 let doc = description 2293 .map(|d| quote! { #[doc = #d] }) 2294 .unwrap_or_else(|| quote! {}); 2295 2296 // Only add open_union if not closed 2297 let is_open = closed != Some(true); 2298 2299 // Generate IntoStatic impl 2300 // let variant_info: Vec<(String, EnumVariantKind)> = refs 2301 // .iter() 2302 // .filter_map(|ref_str| { 2303 // // Skip unknown refs 2304 // if !self.corpus.ref_exists(ref_str.as_ref()) { 2305 // return None; 2306 // } 2307 2308 // let (ref_nsid, ref_def) = if let Some((nsid, fragment)) = ref_str.split_once('#') { 2309 // (nsid, fragment) 2310 // } else { 2311 // (ref_str.as_ref(), "main") 2312 // }; 2313 2314 // let variant_name = if ref_def == "main" { 2315 // ref_nsid.split('.').last().unwrap().to_pascal_case() 2316 // } else { 2317 // let last_segment = ref_nsid.split('.').last().unwrap().to_pascal_case(); 2318 // format!("{}{}", last_segment, ref_def.to_pascal_case()) 2319 // }; 2320 // Some((variant_name, EnumVariantKind::Tuple)) 2321 // }) 2322 // .collect(); 2323 // let into_static_impl = 2324 // self.generate_into_static_for_enum(union_name, &variant_info, true, is_open); 2325 2326 if is_open { 2327 Ok(quote! { 2328 #doc 2329 #[jacquard_derive::open_union] 2330 #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq, jacquard_derive::IntoStatic)] 2331 #[serde(tag = "$type")] 2332 #[serde(bound(deserialize = "'de: 'a"))] 2333 pub enum #enum_ident<'a> { 2334 #(#variants,)* 2335 } 2336 2337 //#into_static_impl 2338 }) 2339 } else { 2340 Ok(quote! { 2341 #doc 2342 #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq, jacquard_derive::IntoStatic)] 2343 #[serde(tag = "$type")] 2344 #[serde(bound(deserialize = "'de: 'a"))] 2345 pub enum #enum_ident<'a> { 2346 #(#variants,)* 2347 } 2348 2349 //#into_static_impl 2350 }) 2351 } 2352 } 2353 2354 /// Generate IntoStatic impl for a struct 2355 #[allow(dead_code)] 2356 fn generate_into_static_for_struct( 2357 &self, 2358 type_name: &str, 2359 field_names: &[&str], 2360 has_lifetime: bool, 2361 has_extra_data: bool, 2362 ) -> TokenStream { 2363 let ident = syn::Ident::new(type_name, proc_macro2::Span::call_site()); 2364 2365 let field_idents: Vec<_> = field_names 2366 .iter() 2367 .map(|name| make_ident(&name.to_snake_case())) 2368 .collect(); 2369 2370 if has_lifetime { 2371 let field_conversions: Vec<_> = field_idents 2372 .iter() 2373 .map(|field| quote! { #field: self.#field.into_static() }) 2374 .collect(); 2375 2376 let extra_data_conversion = if has_extra_data { 2377 quote! { extra_data: self.extra_data.into_static(), } 2378 } else { 2379 quote! {} 2380 }; 2381 2382 quote! { 2383 impl jacquard_common::IntoStatic for #ident<'_> { 2384 type Output = #ident<'static>; 2385 2386 fn into_static(self) -> Self::Output { 2387 #ident { 2388 #(#field_conversions,)* 2389 #extra_data_conversion 2390 } 2391 } 2392 } 2393 } 2394 } else { 2395 quote! { 2396 impl jacquard_common::IntoStatic for #ident { 2397 type Output = #ident; 2398 2399 fn into_static(self) -> Self::Output { 2400 self 2401 } 2402 } 2403 } 2404 } 2405 } 2406 2407 /// Generate IntoStatic impl for an enum 2408 fn generate_into_static_for_enum( 2409 &self, 2410 type_name: &str, 2411 variant_info: &[(String, EnumVariantKind)], 2412 has_lifetime: bool, 2413 is_open: bool, 2414 ) -> TokenStream { 2415 let ident = syn::Ident::new(type_name, proc_macro2::Span::call_site()); 2416 2417 if has_lifetime { 2418 let variant_conversions: Vec<_> = variant_info 2419 .iter() 2420 .map(|(variant_name, kind)| { 2421 let variant_ident = syn::Ident::new(variant_name, proc_macro2::Span::call_site()); 2422 match kind { 2423 EnumVariantKind::Unit => { 2424 quote! { 2425 #ident::#variant_ident => #ident::#variant_ident 2426 } 2427 } 2428 EnumVariantKind::Tuple => { 2429 quote! { 2430 #ident::#variant_ident(v) => #ident::#variant_ident(v.into_static()) 2431 } 2432 } 2433 EnumVariantKind::Struct(fields) => { 2434 let field_idents: Vec<_> = fields 2435 .iter() 2436 .map(|f| make_ident(&f.to_snake_case())) 2437 .collect(); 2438 let field_conversions: Vec<_> = field_idents 2439 .iter() 2440 .map(|f| quote! { #f: #f.into_static() }) 2441 .collect(); 2442 quote! { 2443 #ident::#variant_ident { #(#field_idents,)* } => #ident::#variant_ident { 2444 #(#field_conversions,)* 2445 } 2446 } 2447 } 2448 } 2449 }) 2450 .collect(); 2451 2452 let unknown_conversion = if is_open { 2453 quote! { 2454 #ident::Unknown(v) => #ident::Unknown(v.into_static()), 2455 } 2456 } else { 2457 quote! {} 2458 }; 2459 2460 quote! { 2461 impl jacquard_common::IntoStatic for #ident<'_> { 2462 type Output = #ident<'static>; 2463 2464 fn into_static(self) -> Self::Output { 2465 match self { 2466 #(#variant_conversions,)* 2467 #unknown_conversion 2468 } 2469 } 2470 } 2471 } 2472 } else { 2473 quote! { 2474 impl jacquard_common::IntoStatic for #ident { 2475 type Output = #ident; 2476 2477 fn into_static(self) -> Self::Output { 2478 self 2479 } 2480 } 2481 } 2482 } 2483 } 2484} 2485 2486/// Enum variant kind for IntoStatic generation 2487#[derive(Debug, Clone)] 2488#[allow(dead_code)] 2489enum EnumVariantKind { 2490 Unit, 2491 Tuple, 2492 Struct(Vec<String>), 2493} 2494 2495#[cfg(test)] 2496mod tests { 2497 use super::*; 2498 2499 #[test] 2500 fn test_generate_record() { 2501 let corpus = 2502 LexiconCorpus::load_from_dir("tests/fixtures/test_lexicons").expect("load corpus"); 2503 let codegen = CodeGenerator::new(&corpus, "jacquard_api"); 2504 2505 let doc = corpus.get("app.bsky.feed.post").expect("get post"); 2506 let def = doc.defs.get("main").expect("get main def"); 2507 2508 let tokens = codegen 2509 .generate_def("app.bsky.feed.post", "main", def) 2510 .expect("generate"); 2511 2512 // Format and print for inspection 2513 let file: syn::File = syn::parse2(tokens).expect("parse tokens"); 2514 let formatted = prettyplease::unparse(&file); 2515 println!("\n{}\n", formatted); 2516 2517 // Check basic structure 2518 assert!(formatted.contains("struct Post")); 2519 assert!(formatted.contains("pub text")); 2520 assert!(formatted.contains("CowStr<'a>")); 2521 } 2522 2523 #[test] 2524 fn test_generate_union() { 2525 let corpus = 2526 LexiconCorpus::load_from_dir("tests/fixtures/test_lexicons").expect("load corpus"); 2527 let codegen = CodeGenerator::new(&corpus, "jacquard_api"); 2528 2529 // Create a union with embed types 2530 let refs = vec![ 2531 "app.bsky.embed.images".into(), 2532 "app.bsky.embed.video".into(), 2533 "app.bsky.embed.external".into(), 2534 ]; 2535 2536 let tokens = codegen 2537 .generate_union("RecordEmbed", &refs, Some("Post embed union"), None) 2538 .expect("generate union"); 2539 2540 let file: syn::File = syn::parse2(tokens).expect("parse tokens"); 2541 let formatted = prettyplease::unparse(&file); 2542 println!("\n{}\n", formatted); 2543 2544 // Check structure 2545 assert!(formatted.contains("enum RecordEmbed")); 2546 assert!(formatted.contains("Images")); 2547 assert!(formatted.contains("Video")); 2548 assert!(formatted.contains("External")); 2549 assert!(formatted.contains("#[serde(tag = \"$type\")]")); 2550 assert!(formatted.contains("#[jacquard_derive::open_union]")); 2551 } 2552 2553 #[test] 2554 fn test_generate_query() { 2555 let corpus = 2556 LexiconCorpus::load_from_dir("tests/fixtures/test_lexicons").expect("load corpus"); 2557 let codegen = CodeGenerator::new(&corpus, "jacquard_api"); 2558 2559 let doc = corpus 2560 .get("app.bsky.feed.getAuthorFeed") 2561 .expect("get getAuthorFeed"); 2562 let def = doc.defs.get("main").expect("get main def"); 2563 2564 let tokens = codegen 2565 .generate_def("app.bsky.feed.getAuthorFeed", "main", def) 2566 .expect("generate"); 2567 2568 let file: syn::File = syn::parse2(tokens).expect("parse tokens"); 2569 let formatted = prettyplease::unparse(&file); 2570 println!("\n{}\n", formatted); 2571 2572 // Check structure 2573 assert!(formatted.contains("struct GetAuthorFeed")); 2574 assert!(formatted.contains("struct GetAuthorFeedOutput")); 2575 assert!(formatted.contains("enum GetAuthorFeedError")); 2576 assert!(formatted.contains("pub actor")); 2577 assert!(formatted.contains("pub limit")); 2578 assert!(formatted.contains("pub cursor")); 2579 assert!(formatted.contains("pub feed")); 2580 assert!(formatted.contains("BlockedActor")); 2581 assert!(formatted.contains("BlockedByActor")); 2582 } 2583 2584 #[test] 2585 fn test_generate_known_values_enum() { 2586 let corpus = 2587 LexiconCorpus::load_from_dir("tests/fixtures/test_lexicons").expect("load corpus"); 2588 let codegen = CodeGenerator::new(&corpus, "jacquard_api"); 2589 2590 let doc = corpus 2591 .get("com.atproto.label.defs") 2592 .expect("get label defs"); 2593 let def = doc.defs.get("labelValue").expect("get labelValue def"); 2594 2595 let tokens = codegen 2596 .generate_def("com.atproto.label.defs", "labelValue", def) 2597 .expect("generate"); 2598 2599 let file: syn::File = syn::parse2(tokens).expect("parse tokens"); 2600 let formatted = prettyplease::unparse(&file); 2601 println!("\n{}\n", formatted); 2602 2603 // Check structure 2604 assert!(formatted.contains("enum LabelValue")); 2605 assert!(formatted.contains("Hide")); 2606 assert!(formatted.contains("NoPromote")); 2607 assert!(formatted.contains("Warn")); 2608 assert!(formatted.contains("DmcaViolation")); 2609 assert!(formatted.contains("Other(jacquard_common::CowStr")); 2610 assert!(formatted.contains("impl<'a> From<&'a str>")); 2611 assert!(formatted.contains("fn as_str(&self)")); 2612 } 2613 2614 #[test] 2615 fn test_nsid_to_file_path() { 2616 let corpus = 2617 LexiconCorpus::load_from_dir("tests/fixtures/test_lexicons").expect("load corpus"); 2618 let codegen = CodeGenerator::new(&corpus, "jacquard_api"); 2619 2620 // Regular paths 2621 assert_eq!( 2622 codegen.nsid_to_file_path("app.bsky.feed.post"), 2623 std::path::PathBuf::from("app_bsky/feed/post.rs") 2624 ); 2625 2626 assert_eq!( 2627 codegen.nsid_to_file_path("app.bsky.feed.getAuthorFeed"), 2628 std::path::PathBuf::from("app_bsky/feed/get_author_feed.rs") 2629 ); 2630 2631 // Defs paths - should go in parent 2632 assert_eq!( 2633 codegen.nsid_to_file_path("com.atproto.label.defs"), 2634 std::path::PathBuf::from("com_atproto/label.rs") 2635 ); 2636 } 2637 2638 #[test] 2639 fn test_write_to_disk() { 2640 let corpus = 2641 LexiconCorpus::load_from_dir("tests/fixtures/test_lexicons").expect("load corpus"); 2642 let codegen = CodeGenerator::new(&corpus, "test_generated"); 2643 2644 let tmp_dir = 2645 tempfile::tempdir().expect("should be able to create temp directory for output"); 2646 let output_dir = std::path::PathBuf::from(tmp_dir.path()); 2647 2648 // Clean up any previous test output 2649 let _ = std::fs::remove_dir_all(&output_dir); 2650 2651 // Generate and write 2652 codegen.write_to_disk(&output_dir).expect("write to disk"); 2653 2654 // Verify some files were created 2655 assert!(output_dir.join("app_bsky/feed/post.rs").exists()); 2656 assert!(output_dir.join("app_bsky/feed/get_author_feed.rs").exists()); 2657 assert!(output_dir.join("com_atproto/label.rs").exists()); 2658 2659 // Verify module files were created 2660 assert!(output_dir.join("lib.rs").exists()); 2661 assert!(output_dir.join("app_bsky.rs").exists()); 2662 2663 // Read and verify post.rs contains expected content 2664 let post_content = std::fs::read_to_string(output_dir.join("app_bsky/feed/post.rs")) 2665 .expect("read post.rs"); 2666 assert!(post_content.contains("pub struct Post")); 2667 assert!(post_content.contains("jacquard_common")); 2668 } 2669}