irongit

fix: setting option when necessary

huncholanehuncholaneauthored
parent 15741eacommit e5998b101df18f7fae39cab8b7a858f595fd5302Browse files

5 files changed, +406 -353

+5-0Cargo.toml
@@ -34,3 +34,8 @@ private_tests = []
3434 name = "private_schema_tests"
3535 path = "tests/private/schema_tests.rs"
3636 required-features = ["private_tests"]
37+
38+[[test]]
39+name = "private_codegen_compile"
40+path = "tests/private/codegen_compile_test.rs"
41+required-features = ["private_tests"]
+373-0src/codegen.rs
@@ -200,6 +200,379 @@ pub fn localize_type(rust_type: &str, lang: &dyn crate::lang::LanguageGenerator)
200200 }
201201 }
202202
203+/// A generated project file (name + code content)
204+pub struct GeneratedProjectFile {
205+ pub name: String,
206+ pub code: String,
207+ /// Root type for each source file that contributed to this group.
208+ /// Vec of (source_filename, root_rust_type) — used for deserialization testing.
209+ pub root_types: Vec<(String, String)>,
210+}
211+
212+/// Generate all Rust files for a project: shared.rs, per-group files, mod.rs.
213+/// This mirrors the code view's `rebuild_file_mode` pipeline exactly.
214+pub fn generate_project(
215+ parsed_files: &[(String, serde_json::Value)],
216+ schema: &crate::schema::SchemaOverview,
217+ lang: &dyn crate::lang::LanguageGenerator,
218+) -> Vec<GeneratedProjectFile> {
219+ use std::collections::{BTreeMap, BTreeSet};
220+
221+ let shared_names: BTreeSet<String> = schema.structs.iter().map(|s| s.name.clone()).collect();
222+ let unique_names: BTreeSet<String> = schema.unique_structs.iter().map(|s| s.name.clone()).collect();
223+ let all_structs = schema.all_structs();
224+
225+ let mut result = Vec::new();
226+
227+ // shared.rs
228+ if !schema.structs.is_empty() {
229+ let code = CodeGenerator::from_schema(&schema.structs).generate_code(lang);
230+ result.push(GeneratedProjectFile {
231+ name: lang.file_name("shared"),
232+ code,
233+ root_types: Vec::new(),
234+ });
235+ }
236+
237+ // Group files by depluralized first word
238+ let mut groups: BTreeMap<String, Vec<(&str, &serde_json::Value)>> = BTreeMap::new();
239+ for (filename, value) in parsed_files {
240+ let word = first_normal_word(filename)
241+ .map(|w| to_pascal_case(&singularize(&w)))
242+ .unwrap_or_else(|| "other".to_string());
243+ let key = singularize(&word.to_ascii_lowercase());
244+ groups.entry(key).or_default().push((filename.as_str(), value));
245+ }
246+
247+ let all_schema_names: BTreeSet<String> = shared_names.iter().chain(unique_names.iter()).cloned().collect();
248+
249+ for (group_key, files) in &groups {
250+ // Collect all struct definitions, merging fields from multiple files
251+ let mut struct_order: Vec<String> = Vec::new();
252+ let mut struct_defs: BTreeMap<String, Vec<GeneratedField>> = BTreeMap::new();
253+ let mut root_types: Vec<(String, String)> = Vec::new();
254+ let mut type_aliases: Vec<String> = Vec::new();
255+ let mut seen_aliases: BTreeSet<String> = BTreeSet::new();
256+
257+ for (filename, value) in files {
258+ let prefix = first_normal_word(filename)
259+ .map(|w| to_pascal_case(&singularize(&w)))
260+ .unwrap_or_default();
261+ let is_root_array = value.is_array();
262+
263+ let (root_name, array_item_name) = if is_root_array {
264+ let singular = singularize(&prefix);
265+ let item_name = if singular.is_empty() {
266+ "Item".to_string()
267+ } else {
268+ let mut s = String::new();
269+ s.push(singular.chars().next().unwrap().to_ascii_uppercase());
270+ s.extend(singular.chars().skip(1));
271+ s
272+ };
273+ (item_name.clone(), Some(item_name))
274+ } else {
275+ let name = if prefix.is_empty() { "Root".to_string() } else { format!("{}Root", prefix) };
276+ (name, None)
277+ };
278+
279+ let deser_type = if is_root_array {
280+ format!("Vec<{}>", root_name)
281+ } else {
282+ root_name.clone()
283+ };
284+ root_types.push((filename.to_string(), deser_type));
285+
286+ let mut gen = CodeGenerator::from_value_named(value, &root_name);
287+
288+ // Schema-aware type resolution
289+ resolve_codegen_against_schema(&mut gen, &all_structs, &shared_names);
290+
291+ // Collect structs, merging duplicates
292+ for s in gen.structs.iter().rev() {
293+ if shared_names.contains(&s.name) {
294+ continue;
295+ }
296+
297+ let prefixed = format!("{}{}", prefix, s.name);
298+ let struct_name = if unique_names.contains(&prefixed) {
299+ prefixed
300+ } else if unique_names.contains(&s.name) {
301+ s.name.clone()
302+ } else if s.name != root_name && !prefix.is_empty() && !s.name.starts_with(&prefix) {
303+ prefixed
304+ } else {
305+ s.name.clone()
306+ };
307+
308+ if let Some(existing) = struct_defs.get_mut(&struct_name) {
309+ // Merge: make fields Optional if missing or Null in this instance
310+ merge_generated_fields(existing, &s.fields);
311+ } else {
312+ struct_order.push(struct_name.clone());
313+ struct_defs.insert(struct_name, s.fields.clone());
314+ }
315+ }
316+
317+ if let Some(ref item_name) = array_item_name {
318+ let alias_name = format!("{}Root", prefix);
319+ if !seen_aliases.contains(&alias_name) {
320+ seen_aliases.insert(alias_name.clone());
321+ let aliased = if shared_names.contains(item_name) {
322+ item_name.clone()
323+ } else if !prefix.is_empty() && !item_name.starts_with(&prefix) {
324+ format!("{}{}", prefix, item_name)
325+ } else {
326+ item_name.clone()
327+ };
328+ type_aliases.push(format!("pub type {} = Vec<{}>;\n", alias_name, aliased));
329+ }
330+ }
331+ }
332+
333+ // Emit code from merged struct definitions
334+ let group_prefix = to_pascal_case(&singularize(group_key));
335+ let mut struct_blocks: Vec<String> = type_aliases;
336+ for struct_name in &struct_order {
337+ let fields = &struct_defs[struct_name];
338+ let prefix = group_prefix.clone();
339+
340+ let mut code = String::new();
341+ code.push_str(&lang.struct_open(struct_name));
342+ let mut field_pairs: Vec<(String, String)> = Vec::new();
343+ for field in fields {
344+ let code_name = lang.field_name(&field.json_name);
345+ let base_type = match &field.resolved_type {
346+ Some(rt) => localize_type(rt, lang),
347+ None => lang.type_name(&field.inferred_type),
348+ };
349+ let resolved_type = if !prefix.is_empty() && !all_schema_names.contains(&base_type) {
350+ prefix_resolved_type(&base_type, &prefix, &all_schema_names, struct_name)
351+ } else {
352+ base_type
353+ };
354+ code.push_str(&lang.field_line(&code_name, &resolved_type, &field.json_name));
355+ field_pairs.push((code_name, field.json_name.clone()));
356+ }
357+ code.push_str(&lang.struct_close(&field_pairs));
358+ struct_blocks.push(code);
359+ }
360+
361+ let mut body = String::new();
362+ for block in &struct_blocks {
363+ body.push_str(block);
364+ body.push('\n');
365+ }
366+
367+ let mut code = String::new();
368+ let header = lang.file_header();
369+ if !header.is_empty() {
370+ code.push_str(&header);
371+ code.push('\n');
372+ }
373+ code.push_str(&lang.imports_header(&body, !shared_names.is_empty()));
374+ code.push('\n');
375+ code.push_str(&body);
376+
377+ result.push(GeneratedProjectFile {
378+ name: lang.file_name(group_key),
379+ code: code.trim_end().to_string() + "\n",
380+ root_types,
381+ });
382+ }
383+
384+ // mod.rs
385+ let mod_names: Vec<&str> = result.iter().map(|f| {
386+ f.name.strip_suffix(".rs").unwrap_or(&f.name)
387+ }).collect();
388+ if let Some(mod_code) = lang.mod_file(&mod_names) {
389+ result.push(GeneratedProjectFile {
390+ name: "mod.rs".to_string(),
391+ code: mod_code,
392+ root_types: Vec::new(),
393+ });
394+ }
395+
396+ result
397+}
398+
399+/// Resolve types in a CodeGenerator against schema structs, and generate
400+/// missing struct definitions from schema when referenced but not present.
401+pub fn resolve_codegen_against_schema(
402+ gen: &mut CodeGenerator,
403+ all_structs: &[crate::schema::SharedStruct],
404+ shared_names: &std::collections::BTreeSet<String>,
405+) {
406+ for s in &mut gen.structs {
407+ let schema_match = crate::types::resolve_struct_name(
408+ &s.fields.iter().map(|f| (f.json_name.clone(), f.inferred_type.clone())).collect(),
409+ all_structs,
410+ );
411+ let schema_fields = schema_match.and_then(|name| {
412+ all_structs.iter().find(|ss| ss.name == name)
413+ });
414+
415+ for field in &mut s.fields {
416+ if field.resolved_type.is_none() {
417+ field.resolved_type = resolve_type_to_struct(&field.inferred_type, all_structs);
418+ if field.resolved_type.is_none() {
419+ if let Some(ss) = schema_fields {
420+ if let Some(schema_type) = ss.fields.get(&field.json_name) {
421+ field.resolved_type = resolve_type_to_struct(schema_type, all_structs);
422+ if field.resolved_type.is_some() {
423+ field.inferred_type = schema_type.clone();
424+ }
425+ }
426+ }
427+ }
428+ }
429+ }
430+ }
431+
432+ // Generate missing structs from schema
433+ let existing_names: std::collections::BTreeSet<String> =
434+ gen.structs.iter().map(|s| s.name.clone()).collect();
435+ let mut needed: Vec<String> = Vec::new();
436+ for s in &gen.structs {
437+ for field in &s.fields {
438+ if let Some(rt) = &field.resolved_type {
439+ for name in extract_struct_names_from_resolved(rt) {
440+ if !existing_names.contains(&name) && !shared_names.contains(&name) {
441+ needed.push(name);
442+ }
443+ }
444+ }
445+ }
446+ }
447+ let mut added: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
448+ while let Some(name) = needed.pop() {
449+ if added.contains(&name) || existing_names.contains(&name) || shared_names.contains(&name) {
450+ continue;
451+ }
452+ added.insert(name.clone());
453+ if let Some(ss) = all_structs.iter().find(|ss| ss.name == name) {
454+ let fields: Vec<GeneratedField> = ss.fields.iter().map(|(key, typ)| {
455+ let resolved = resolve_type_to_struct(typ, all_structs);
456+ if let Some(rt) = &resolved {
457+ for dep in extract_struct_names_from_resolved(rt) {
458+ needed.push(dep);
459+ }
460+ }
461+ GeneratedField {
462+ json_name: key.clone(),
463+ inferred_type: typ.clone(),
464+ resolved_type: resolved,
465+ needs_rename: false,
466+ }
467+ }).collect();
468+ gen.structs.push(GeneratedStruct {
469+ name: name.clone(),
470+ fields,
471+ });
472+ }
473+ }
474+}
475+
476+/// Merge a new set of fields into an existing field list.
477+/// Fields missing from the new set, or whose type is Null, become Option<T>.
478+fn merge_generated_fields(existing: &mut Vec<GeneratedField>, new_fields: &[GeneratedField]) {
479+ use std::collections::BTreeMap;
480+
481+ let new_map: BTreeMap<&str, &GeneratedField> = new_fields
482+ .iter()
483+ .map(|f| (f.json_name.as_str(), f))
484+ .collect();
485+
486+ for field in existing.iter_mut() {
487+ match new_map.get(field.json_name.as_str()) {
488+ None => {
489+ // Field missing in new instance — make it Optional
490+ if !matches!(field.inferred_type, InferredType::Option(_) | InferredType::Null) {
491+ field.inferred_type = InferredType::Option(Box::new(field.inferred_type.clone()));
492+ field.resolved_type = field.resolved_type.take().map(|rt| {
493+ if rt.starts_with("Option<") { rt } else { format!("Option<{}>", rt) }
494+ });
495+ }
496+ }
497+ Some(new_field) => {
498+ // Field present but type might differ (e.g., String vs Null)
499+ if new_field.inferred_type == InferredType::Null
500+ && !matches!(field.inferred_type, InferredType::Option(_) | InferredType::Null)
501+ {
502+ field.inferred_type = InferredType::Option(Box::new(field.inferred_type.clone()));
503+ field.resolved_type = field.resolved_type.take().map(|rt| {
504+ if rt.starts_with("Option<") { rt } else { format!("Option<{}>", rt) }
505+ });
506+ }
507+ }
508+ }
509+ }
510+
511+ // Add fields that exist in new but not in existing (as Optional)
512+ let existing_names: BTreeSet<String> = existing.iter().map(|f| f.json_name.clone()).collect();
513+ for new_field in new_fields {
514+ if !existing_names.contains(&new_field.json_name) {
515+ let mut field = new_field.clone();
516+ if !matches!(field.inferred_type, InferredType::Option(_) | InferredType::Null) {
517+ field.inferred_type = InferredType::Option(Box::new(field.inferred_type.clone()));
518+ field.resolved_type = field.resolved_type.take().map(|rt| {
519+ if rt.starts_with("Option<") { rt } else { format!("Option<{}>", rt) }
520+ });
521+ }
522+ existing.push(field);
523+ }
524+ }
525+}
526+
527+fn extract_struct_names_from_resolved(rt: &str) -> Vec<String> {
528+ let mut names = Vec::new();
529+ let inner = rt
530+ .strip_prefix("Option<").and_then(|s| s.strip_suffix('>'))
531+ .or_else(|| rt.strip_prefix("Vec<").and_then(|s| s.strip_suffix('>')));
532+ if let Some(inner) = inner {
533+ names.extend(extract_struct_names_from_resolved(inner));
534+ } else if !rt.is_empty() && rt.chars().next().unwrap().is_ascii_uppercase() {
535+ names.push(rt.to_string());
536+ }
537+ names
538+}
539+
540+fn is_struct_name(s: &str) -> bool {
541+ let first = s.chars().next().unwrap_or('a');
542+ first.is_ascii_uppercase()
543+ && !s.contains('<')
544+ && !s.contains('[')
545+ && !matches!(
546+ s,
547+ "String" | "Vec" | "Option" | "bool" | "i64" | "u64" | "f64" | "i32" | "u32" | "f32"
548+ | "NaiveDate" | "NaiveTime"
549+ | "Bool" | "Int" | "Double" | "Date" | "Any"
550+ )
551+}
552+
553+fn prefix_resolved_type(
554+ type_str: &str,
555+ prefix: &str,
556+ shared_names: &std::collections::BTreeSet<String>,
557+ root_name: &str,
558+) -> String {
559+ if type_str.starts_with("Vec<") && type_str.ends_with('>') {
560+ let inner = &type_str[4..type_str.len() - 1];
561+ format!("Vec<{}>", prefix_resolved_type(inner, prefix, shared_names, root_name))
562+ } else if type_str.starts_with("Option<") && type_str.ends_with('>') {
563+ let inner = &type_str[7..type_str.len() - 1];
564+ format!("Option<{}>", prefix_resolved_type(inner, prefix, shared_names, root_name))
565+ } else if is_struct_name(type_str)
566+ && !shared_names.contains(type_str)
567+ && type_str != root_name
568+ && !type_str.starts_with(prefix)
569+ {
570+ format!("{}{}", prefix, type_str)
571+ } else {
572+ type_str.to_string()
573+ }
574+}
575+
203576 pub fn to_snake_case(s: &str) -> String {
204577 let mut result = String::new();
205578 for (i, ch) in s.chars().enumerate() {
+13-4src/lang/rust.rs
@@ -52,7 +52,7 @@ impl LanguageGenerator for RustGenerator {
5252
5353 fn struct_open(&self, name: &str) -> String {
5454 format!(
55- "#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct {} {{\n",
55+ "#[derive(Debug, Clone, Default, Serialize, Deserialize)]\n#[serde(default)]\npub struct {} {{\n",
5656 name
5757 )
5858 }
@@ -67,13 +67,13 @@ impl LanguageGenerator for RustGenerator {
6767
6868 fn enum_open(&self, name: &str) -> String {
6969 format!(
70- "#[derive(Debug, Clone, Serialize, Deserialize)]\npub enum {} {{\n",
70+ "#[derive(Debug, Clone, Default, Serialize, Deserialize)]\npub enum {} {{\n",
7171 name
7272 )
7373 }
7474
7575 fn enum_close(&self) -> String {
76- "}\n".to_string()
76+ " #[default]\n #[serde(other)]\n Unknown,\n}\n".to_string()
7777 }
7878
7979 fn enum_variant(&self, variant_name: &str, json_value: &str) -> String {
@@ -88,7 +88,16 @@ impl LanguageGenerator for RustGenerator {
8888 }
8989
9090 fn type_name(&self, inferred: &InferredType) -> String {
91- inferred.rust_type()
91+ match inferred {
92+ // Temporal types vary in format — String is the safe codegen choice
93+ InferredType::DateTime | InferredType::Date | InferredType::Time => {
94+ "String".to_string()
95+ }
96+ // Recurse into wrappers so Option<DateTime> → Option<String>, etc.
97+ InferredType::Option(inner) => format!("Option<{}>", self.type_name(inner)),
98+ InferredType::Array(inner) => format!("Vec<{}>", self.type_name(inner)),
99+ _ => inferred.rust_type(),
100+ }
92101 }
93102
94103 fn mod_file(&self, file_names: &[&str]) -> Option<String> {
+15-344src/views/code.rs
@@ -2,7 +2,6 @@ use std::collections::{BTreeMap, BTreeSet};
22
33 use egui::{self, RichText, Ui};
44
5-use crate::codegen::{CodeGenerator, localize_type};
65 use crate::theme::CatppuccinMocha;
76
87 struct StructBlock {
@@ -346,86 +345,28 @@ impl CodeView {
346345 self.files.clear();
347346 let lang = self.selected_language.generator();
348347
349- // shared file from schema (if available)
350- let shared_struct_names: std::collections::BTreeSet<String> = schema
351- .map(|s| s.structs.iter().map(|st| st.name.clone()).collect())
352- .unwrap_or_default();
353-
354- // unique struct names from schema — used to match disambiguated names
355- let unique_struct_names: std::collections::BTreeSet<String> = schema
356- .map(|s| s.unique_structs.iter().map(|st| st.name.clone()).collect())
357- .unwrap_or_default();
358-
359- // All structs for resolving Object types to struct names in per-file codegen
360- let all_schema_structs: Vec<crate::schema::SharedStruct> = schema
361- .map(|s| s.all_structs())
362- .unwrap_or_default();
363-
364- if let Some(s) = schema {
365- if !s.structs.is_empty() {
366- let code = CodeGenerator::from_schema(&s.structs).generate_code(lang.as_ref());
367- let lines: Vec<String> = code.lines().map(|l| l.to_string()).collect();
368- let struct_blocks = extract_struct_blocks(&lines, self.selected_language);
369- self.files.push(GeneratedFile {
370- name: lang.file_name("shared"),
371- code,
372- lines,
373- structs: struct_blocks,
374- is_group: false,
375- });
376- }
377- }
378-
379- // Group files by depluralized first word
380- let mut groups: std::collections::BTreeMap<String, Vec<(&str, &serde_json::Value)>> =
381- std::collections::BTreeMap::new();
382- for (filename, value) in parsed_files {
383- let word = first_normal_word(filename).unwrap_or_else(|| "other".to_string());
384- let key = crate::codegen::singularize(&word.to_ascii_lowercase());
385- groups.entry(key).or_default().push((filename.as_str(), value));
386- }
387-
388- // Generate one file per group, merging struct definitions
389- for (group_key, files) in &groups {
390- let mut seen_structs: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
391- let mut struct_blocks: Vec<String> = Vec::new();
392-
393- for (filename, value) in files {
394- let raw = generate_file_code_structs(value, &shared_struct_names, &unique_struct_names, &all_schema_structs, filename, lang.as_ref());
395- for block in &raw {
396- if !seen_structs.contains(&block.name) {
397- seen_structs.insert(block.name.clone());
398- struct_blocks.push(block.code.clone());
399- }
400- }
401- }
348+ let empty_schema = crate::schema::SchemaOverview {
349+ structs: Vec::new(),
350+ unique_structs: Vec::new(),
351+ };
352+ let schema = schema.unwrap_or(&empty_schema);
402353
403- // Build struct body first, then generate imports based on what's used
404- let mut body = String::new();
405- for block in &struct_blocks {
406- body.push_str(block);
407- body.push('\n');
408- }
354+ let project = crate::codegen::generate_project(parsed_files, schema, lang.as_ref());
355+ let shared_file_name = lang.file_name("shared");
409356
410- let mut merged_code = String::new();
411- let header = lang.file_header();
412- if !header.is_empty() {
413- merged_code.push_str(&header);
414- merged_code.push('\n');
357+ for pf in &project {
358+ if pf.name == "mod.rs" {
359+ continue;
415360 }
416- merged_code.push_str(&lang.imports_header(&body, !shared_struct_names.is_empty()));
417- merged_code.push('\n');
418- merged_code.push_str(&body);
419-
420- let code = merged_code.trim_end().to_string() + "\n";
421- let lines: Vec<String> = code.lines().map(|l| l.to_string()).collect();
361+ let lines: Vec<String> = pf.code.lines().map(|l| l.to_string()).collect();
422362 let struct_blocks = extract_struct_blocks(&lines, self.selected_language);
363+ let is_group = pf.name != shared_file_name;
423364 self.files.push(GeneratedFile {
424- name: lang.file_name(group_key),
425- code,
365+ name: pf.name.clone(),
366+ code: pf.code.clone(),
426367 lines,
427368 structs: struct_blocks,
428- is_group: true,
369+ is_group,
429370 });
430371 }
431372
@@ -1287,276 +1228,6 @@ impl CodeView {
12871228 }
12881229 }
12891230
1290-/// Wrapper around codegen::first_normal_word that singularizes + PascalCases the result,
1291-/// matching the naming scheme used by schema disambiguation.
1292-fn first_normal_word(filename: &str) -> Option<String> {
1293- crate::codegen::first_normal_word(filename).map(|w| {
1294- let singular = crate::codegen::singularize(&w);
1295- crate::codegen::to_pascal_case(&singular)
1296- })
1297-}
1298-
1299-struct NamedCodeBlock {
1300- name: String,
1301- code: String,
1302-}
1303-
1304-/// Generate individual struct blocks for a file (used for grouping/merging)
1305-fn generate_file_code_structs(
1306- value: &serde_json::Value,
1307- shared_names: &std::collections::BTreeSet<String>,
1308- unique_names: &std::collections::BTreeSet<String>,
1309- schema_structs: &[crate::schema::SharedStruct],
1310- filename: &str,
1311- lang: &dyn crate::lang::LanguageGenerator,
1312-) -> Vec<NamedCodeBlock> {
1313- let prefix = first_normal_word(filename).unwrap_or_default();
1314- let is_root_array = value.is_array();
1315-
1316- let (root_name, array_item_name) = if is_root_array {
1317- let singular = crate::codegen::singularize(&prefix);
1318- let item_name = if singular.is_empty() {
1319- "Item".to_string()
1320- } else {
1321- let mut s = String::new();
1322- s.push(singular.chars().next().unwrap().to_ascii_uppercase());
1323- s.extend(singular.chars().skip(1));
1324- s
1325- };
1326- (item_name.clone(), Some(item_name))
1327- } else {
1328- let name = if prefix.is_empty() {
1329- "Root".to_string()
1330- } else {
1331- format!("{}Root", prefix)
1332- };
1333- (name, None)
1334- };
1335-
1336- let mut gen = CodeGenerator::from_value_named(value, &root_name);
1337-
1338- // Resolve unresolved fields against schema structs.
1339- // The per-file codegen infers types from one JSON instance, so nullable fields
1340- // (e.g. jumpseatReservations: null) lose their real type. Look up the schema's
1341- // merged type for those fields and resolve against known structs.
1342- if !schema_structs.is_empty() {
1343- for s in &mut gen.structs {
1344- // Find the matching schema struct by field overlap
1345- let schema_match = crate::types::resolve_struct_name(
1346- &s.fields.iter().map(|f| (f.json_name.clone(), f.inferred_type.clone())).collect(),
1347- schema_structs,
1348- );
1349- let schema_fields = schema_match.and_then(|name| {
1350- schema_structs.iter().find(|ss| ss.name == name)
1351- });
1352-
1353- for field in &mut s.fields {
1354- if field.resolved_type.is_none() {
1355- // First try resolving from the field's own inferred type
1356- field.resolved_type =
1357- crate::codegen::resolve_type_to_struct(&field.inferred_type, schema_structs);
1358-
1359- // If still unresolved, try the schema's merged type for this field
1360- if field.resolved_type.is_none() {
1361- if let Some(ss) = schema_fields {
1362- if let Some(schema_type) = ss.fields.get(&field.json_name) {
1363- field.resolved_type =
1364- crate::codegen::resolve_type_to_struct(schema_type, schema_structs);
1365- // Also update inferred_type so rust_type() fallback is correct
1366- if field.resolved_type.is_some() {
1367- field.inferred_type = schema_type.clone();
1368- }
1369- }
1370- }
1371- }
1372- }
1373- }
1374- }
1375-
1376- // Generate structs from schema for types referenced but not generated
1377- // (happens when JSON value was null so collect_structs never saw the object)
1378- let existing_names: std::collections::BTreeSet<String> =
1379- gen.structs.iter().map(|s| s.name.clone()).collect();
1380- let mut needed: Vec<String> = Vec::new();
1381- for s in &gen.structs {
1382- for field in &s.fields {
1383- if let Some(rt) = &field.resolved_type {
1384- for name in extract_struct_names_from_resolved(rt) {
1385- if !existing_names.contains(&name) && !shared_names.contains(&name) {
1386- needed.push(name);
1387- }
1388- }
1389- }
1390- }
1391- }
1392-
1393- // Add missing structs from schema, and recurse for their dependencies
1394- let mut added: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1395- while let Some(name) = needed.pop() {
1396- if added.contains(&name) || existing_names.contains(&name) || shared_names.contains(&name) {
1397- continue;
1398- }
1399- added.insert(name.clone());
1400- if let Some(ss) = schema_structs.iter().find(|ss| ss.name == name) {
1401- let fields: Vec<crate::codegen::GeneratedField> = ss.fields.iter().map(|(key, typ)| {
1402- let resolved = crate::codegen::resolve_type_to_struct(typ, schema_structs);
1403- // Queue any newly referenced structs
1404- if let Some(rt) = &resolved {
1405- for dep in extract_struct_names_from_resolved(rt) {
1406- needed.push(dep);
1407- }
1408- }
1409- crate::codegen::GeneratedField {
1410- json_name: key.clone(),
1411- inferred_type: typ.clone(),
1412- resolved_type: resolved,
1413- needs_rename: false,
1414- }
1415- }).collect();
1416- gen.structs.push(crate::codegen::GeneratedStruct {
1417- name: name.clone(),
1418- fields,
1419- });
1420- }
1421- }
1422- }
1423-
1424- let mut blocks = Vec::new();
1425-
1426- // Type alias for array roots
1427- if let Some(ref item_name) = array_item_name {
1428- let aliased = if shared_names.contains(item_name) {
1429- item_name.clone()
1430- } else if !prefix.is_empty() && !item_name.starts_with(&prefix) {
1431- format!("{}{}", prefix, item_name)
1432- } else {
1433- item_name.clone()
1434- };
1435- blocks.push(NamedCodeBlock {
1436- name: format!("{}Root", prefix),
1437- code: format!("pub type {}Root = Vec<{}>;\n", prefix, aliased),
1438- });
1439- }
1440-
1441- // Combine shared + unique names for type resolution
1442- let all_schema_names: std::collections::BTreeSet<String> = shared_names
1443- .iter()
1444- .chain(unique_names.iter())
1445- .cloned()
1446- .collect();
1447-
1448- for s in gen.structs.iter().rev() {
1449- if shared_names.contains(&s.name) {
1450- continue;
1451- }
1452-
1453- // Use schema-disambiguated name if available, otherwise prefix as before
1454- let prefixed = format!("{}{}", prefix, s.name);
1455- let struct_name = if unique_names.contains(&prefixed) {
1456- prefixed
1457- } else if unique_names.contains(&s.name) {
1458- s.name.clone()
1459- } else if s.name != root_name && !prefix.is_empty() && !s.name.starts_with(&prefix) {
1460- prefixed
1461- } else {
1462- s.name.clone()
1463- };
1464-
1465- let mut code = String::new();
1466- code.push_str(&lang.struct_open(&struct_name));
1467- let mut field_pairs: Vec<(String, String)> = Vec::new();
1468- for field in &s.fields {
1469- let code_name = lang.field_name(&field.json_name);
1470- let base_type = match &field.resolved_type {
1471- Some(rt) => localize_type(rt, lang),
1472- None => lang.type_name(&field.inferred_type),
1473- };
1474- let resolved_type = if !prefix.is_empty() && !all_schema_names.contains(&base_type) {
1475- prefix_type(&base_type, &prefix, &all_schema_names, &root_name)
1476- } else {
1477- base_type
1478- };
1479- code.push_str(&lang.field_line(&code_name, &resolved_type, &field.json_name));
1480- field_pairs.push((code_name, field.json_name.clone()));
1481- }
1482- code.push_str(&lang.struct_close(&field_pairs));
1483-
1484- blocks.push(NamedCodeBlock {
1485- name: struct_name,
1486- code,
1487- });
1488- }
1489-
1490- blocks
1491-}
1492-
1493-/// Extract bare struct names from a resolved type string like "Option<Vec<Foo>>" → ["Foo"]
1494-fn extract_struct_names_from_resolved(rt: &str) -> Vec<String> {
1495- let mut names = Vec::new();
1496- let inner = rt
1497- .strip_prefix("Option<").and_then(|s| s.strip_suffix('>'))
1498- .or_else(|| rt.strip_prefix("Vec<").and_then(|s| s.strip_suffix('>')));
1499- if let Some(inner) = inner {
1500- names.extend(extract_struct_names_from_resolved(inner));
1501- } else if !rt.is_empty() && rt.chars().next().unwrap().is_ascii_uppercase() {
1502- names.push(rt.to_string());
1503- }
1504- names
1505-}
1506-
1507-/// Prefix struct references in type strings (handles Vec<T>, Option<T>, [T], T?, bare T)
1508-fn prefix_type(
1509- type_str: &str,
1510- prefix: &str,
1511- shared_names: &std::collections::BTreeSet<String>,
1512- root_name: &str,
1513-) -> String {
1514- // Rust: Vec<T>
1515- if type_str.starts_with("Vec<") && type_str.ends_with('>') {
1516- let inner = &type_str[4..type_str.len() - 1];
1517- let prefixed_inner = prefix_type(inner, prefix, shared_names, root_name);
1518- format!("Vec<{}>", prefixed_inner)
1519- // Rust: Option<T>
1520- } else if type_str.starts_with("Option<") && type_str.ends_with('>') {
1521- let inner = &type_str[7..type_str.len() - 1];
1522- let prefixed_inner = prefix_type(inner, prefix, shared_names, root_name);
1523- format!("Option<{}>", prefixed_inner)
1524- // Swift: [T]
1525- } else if type_str.starts_with('[') && type_str.ends_with(']') {
1526- let inner = &type_str[1..type_str.len() - 1];
1527- let prefixed_inner = prefix_type(inner, prefix, shared_names, root_name);
1528- format!("[{}]", prefixed_inner)
1529- // Swift: T?
1530- } else if type_str.ends_with('?') {
1531- let inner = &type_str[..type_str.len() - 1];
1532- let prefixed_inner = prefix_type(inner, prefix, shared_names, root_name);
1533- format!("{}?", prefixed_inner)
1534- } else if is_struct_name(type_str)
1535- && !shared_names.contains(type_str)
1536- && type_str != root_name
1537- && !type_str.starts_with(prefix)
1538- {
1539- format!("{}{}", prefix, type_str)
1540- } else {
1541- type_str.to_string()
1542- }
1543-}
1544-
1545-/// Check if a type name looks like a struct (PascalCase, not a primitive)
1546-fn is_struct_name(s: &str) -> bool {
1547- let first = s.chars().next().unwrap_or('a');
1548- first.is_ascii_uppercase()
1549- && !s.contains('<')
1550- && !s.contains('[')
1551- && !matches!(
1552- s,
1553- // Rust primitives
1554- "String" | "Vec" | "Option" | "bool" | "i64" | "u64" | "f64" | "i32" | "u32" | "f32"
1555- | "NaiveDate" | "NaiveTime"
1556- // Swift primitives
1557- | "Bool" | "Int" | "Double" | "Date" | "Any"
1558- )
1559-}
15601231
15611232 enum LineAction {
15621233 None,
+0-5tests/schema_tests.rs
@@ -2,8 +2,3 @@ use jv::schema::{SchemaOverview, SharedStruct};
22
33 // Public tests — run against committed sample data in samples/public/
44 include!(concat!(env!("CARGO_MANIFEST_DIR"), "/samples/public/schema_tests.rs"));
5-
6-// Private tests — only compiled if the file exists.
7-// To use: add JSON/HAR files to samples/private/ and tests to tests/private/schema_tests.rs
8-#[cfg(feature = "private_tests")]
9-include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/private/schema_tests.rs"));