A better Rust ATProto crate
1use super::nsid_utils::NsidPath;
2use super::utils::{namespace_prefix, sanitize_name, sanitize_name_cow};
3use super::CodeGenerator;
4use heck::{ToPascalCase, ToSnakeCase};
5
6impl<'c> CodeGenerator<'c> {
7 /// Check if a single-variant union is self-referential
8 pub(super) fn is_self_referential_union(
9 &self,
10 nsid: &str,
11 parent_type_name: &str,
12 union: &crate::lexicon::LexRefUnion,
13 ) -> bool {
14 if union.refs.len() != 1 {
15 return false;
16 }
17
18 let ref_str = if union.refs[0].starts_with('#') {
19 format!("{}{}", nsid, union.refs[0])
20 } else {
21 union.refs[0].to_string()
22 };
23
24 let (ref_nsid, ref_def) = if let Some((nsid_part, fragment)) = ref_str.split_once('#') {
25 (nsid_part, fragment)
26 } else {
27 (ref_str.as_str(), "main")
28 };
29
30 let ref_type_name = self.def_to_type_name(ref_nsid, ref_def);
31 ref_type_name == parent_type_name
32 }
33
34 /// Helper to generate field-based type name with collision detection
35 pub(super) fn generate_field_type_name(
36 &self,
37 nsid: &str,
38 parent_type_name: &str,
39 field_name: &str,
40 suffix: &str, // "" for union/object, "Item" for array unions
41 ) -> String {
42 let base_name = format!("{}{}{}", parent_type_name, field_name.to_pascal_case(), suffix);
43
44 // Check for collisions with lexicon defs
45 if let Some(doc) = self.corpus.get(nsid) {
46 let def_names: std::collections::HashSet<String> = doc
47 .defs
48 .keys()
49 .map(|name| self.def_to_type_name(nsid, name.as_ref()))
50 .collect();
51
52 if def_names.contains(&base_name) {
53 // Use "Union" suffix for union types, "Record" for objects
54 let disambiguator = if suffix.is_empty() && !parent_type_name.is_empty() {
55 "Union"
56 } else {
57 "Record"
58 };
59 return format!("{}{}{}{}", parent_type_name, disambiguator, field_name.to_pascal_case(), suffix);
60 }
61 }
62
63 base_name
64 }
65
66 /// Convert lexicon def name to base Rust type name (without prelude collision handling)
67 fn def_to_base_type_name(&self, nsid: &str, def_name: &str) -> String {
68 if def_name == "main" {
69 // Use last segment of NSID
70 let nsid_path = NsidPath::parse(nsid);
71 let base_name = nsid_path.last_segment().to_pascal_case();
72
73 // Check if any other def would collide with this name
74 if let Some(doc) = self.corpus.get(nsid) {
75 let has_collision = doc.defs.keys().any(|other_def| {
76 let other_def_str: &str = other_def.as_ref();
77 other_def_str != "main" && other_def_str.to_pascal_case() == base_name
78 });
79
80 if has_collision {
81 return format!("{}Record", base_name);
82 }
83 }
84
85 base_name
86 } else {
87 def_name.to_pascal_case()
88 }
89 }
90
91 /// Apply prelude collision fix if needed
92 fn apply_prelude_collision_fix(&self, nsid: &str, def_name: &str, base_name: String) -> String {
93 // Prelude types that would shadow if used as type names
94 const PRELUDE_TYPES: &[&str] = &[
95 "Option", "Result", "String", "Vec", "Box",
96 "Some", "None", "Ok", "Err",
97 ];
98
99 if !PRELUDE_TYPES.contains(&base_name.as_str()) {
100 return base_name;
101 }
102
103 // Add contextual prefix to avoid collision
104 if def_name == "main" {
105 // Use second-to-last NSID segment for main defs
106 let nsid_path = NsidPath::parse(nsid);
107 let parts = nsid_path.segments();
108 if parts.len() >= 2 {
109 format!("{}{}", parts[parts.len() - 2].to_pascal_case(), base_name)
110 } else {
111 format!("Lex{}", base_name) // fallback
112 }
113 } else {
114 // Use main def's type name as prefix for nested defs
115 let main_base = self.def_to_base_type_name(nsid, "main");
116 format!("{}{}", main_base, base_name)
117 }
118 }
119
120 /// Convert lexicon def name to Rust type name
121 pub(super) fn def_to_type_name(&self, nsid: &str, def_name: &str) -> String {
122 let base_name = self.def_to_base_type_name(nsid, def_name);
123 self.apply_prelude_collision_fix(nsid, def_name, base_name)
124 }
125
126 /// Convert NSID to file path relative to output directory
127 ///
128 /// - `app.bsky.feed.post` → `app_bsky/feed/post.rs`
129 /// - `com.atproto.label.defs` → `com_atproto/label.rs` (defs go in parent)
130 pub(super) fn nsid_to_file_path(&self, nsid: &str) -> std::path::PathBuf {
131 let nsid_path = NsidPath::parse(nsid);
132 let parts = nsid_path.segments();
133
134 if parts.len() < 2 {
135 // Shouldn't happen with valid NSIDs, but handle gracefully
136 return format!("{}.rs", sanitize_name(parts[0])).into();
137 }
138
139 let last = nsid_path.last_segment();
140
141 if nsid_path.is_defs() && parts.len() >= 3 {
142 // defs go in parent module: com.atproto.label.defs → com_atproto/label.rs
143 let first_two = namespace_prefix(parts[0], parts[1]);
144 if parts.len() == 3 {
145 // com.atproto.defs → com_atproto.rs
146 format!("{}.rs", first_two).into()
147 } else {
148 // com.atproto.label.defs → com_atproto/label.rs
149 let middle: Vec<&str> = parts[2..parts.len() - 1].iter().copied().collect();
150 let mut path = std::path::PathBuf::from(first_two);
151 for segment in &middle[..middle.len() - 1] {
152 path.push(sanitize_name_cow(segment).as_ref());
153 }
154 path.push(format!("{}.rs", sanitize_name_cow(middle.last().unwrap())));
155 path
156 }
157 } else {
158 // Regular path: app.bsky.feed.post → app_bsky/feed/post.rs
159 let first_two = namespace_prefix(parts[0], parts[1]);
160 let mut path = std::path::PathBuf::from(first_two);
161
162 for segment in &parts[2..parts.len() - 1] {
163 path.push(sanitize_name_cow(segment).as_ref());
164 }
165
166 path.push(format!("{}.rs", sanitize_name_cow(&last.to_snake_case())));
167 path
168 }
169 }
170}