openapi_nexus/generators/rust/common/
config.rs1use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6use tracing::error;
7
8#[derive(Debug, Clone, Default, Serialize, Deserialize)]
10pub struct ExtraDeriveConfig {
11 #[serde(default)]
12 pub derives: Vec<String>,
13 #[serde(default)]
14 pub dependencies: BTreeMap<String, String>,
15}
16
17#[derive(Debug, Clone, Default, Serialize, Deserialize)]
19pub struct ExtraDerivesConfig {
20 #[serde(default)]
21 pub structs: Option<ExtraDeriveConfig>,
22 #[serde(default)]
23 pub enums: Option<ExtraDeriveConfig>,
24 #[serde(default)]
25 pub unions: Option<ExtraDeriveConfig>,
26 #[serde(default)]
27 pub response_structs: Option<ExtraDeriveConfig>,
28 #[serde(default)]
29 pub per_type: Option<BTreeMap<String, ExtraDeriveConfig>>,
30}
31
32impl ExtraDerivesConfig {
33 pub fn all_dependencies(&self) -> BTreeMap<String, String> {
35 let mut deps = BTreeMap::new();
36 for cfg in [
37 &self.structs,
38 &self.enums,
39 &self.unions,
40 &self.response_structs,
41 ]
42 .into_iter()
43 .flatten()
44 {
45 for (k, v) in &cfg.dependencies {
46 deps.entry(k.clone()).or_insert_with(|| v.clone());
47 }
48 }
49 if let Some(per_type) = &self.per_type {
50 for cfg in per_type.values() {
51 for (k, v) in &cfg.dependencies {
52 deps.entry(k.clone()).or_insert_with(|| v.clone());
53 }
54 }
55 }
56 deps
57 }
58
59 pub fn warn_missing_dependencies(&self) {
62 let deps = self.all_dependencies();
63 for cfg in [
64 &self.structs,
65 &self.enums,
66 &self.unions,
67 &self.response_structs,
68 ]
69 .into_iter()
70 .flatten()
71 {
72 Self::check_derives(&cfg.derives, &deps);
73 }
74 if let Some(per_type) = &self.per_type {
75 for cfg in per_type.values() {
76 Self::check_derives(&cfg.derives, &deps);
77 }
78 }
79 }
80
81 fn check_derives(derives: &[String], deps: &BTreeMap<String, String>) {
82 for d in derives {
83 if let Some(crate_name) = d.split("::").next()
84 && crate_name != d
85 {
86 let normalized = crate_name.replace('-', "_");
87 if !deps.contains_key(&normalized) {
88 tracing::warn!(
89 "Derive `{d}` references crate `{normalized}` but no matching entry \
90 exists in `extra_derives.*.dependencies`. Add \
91 `[extra_derives.<kind>.dependencies]\n{normalized} = '{{ version = \"...\" }}'` \
92 to include it in the generated Cargo.toml."
93 );
94 }
95 }
96 }
97 }
98}
99
100#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
102#[serde(rename_all = "snake_case")]
103pub enum WorkspaceDepsMode {
104 #[default]
106 Explicit,
107 WorkspaceVersion,
109 Full,
111}
112
113#[derive(Debug, Clone, Default, Serialize, Deserialize)]
115pub struct RustGeneratorConfig {
116 #[serde(default, alias = "package_name")]
117 pub crate_name: Option<String>,
118 #[serde(default)]
119 pub extra_derives: Option<ExtraDerivesConfig>,
120 #[serde(default)]
121 pub workspace_mode: Option<bool>,
122 #[serde(default)]
123 pub workspace_deps: Option<WorkspaceDepsMode>,
124}
125
126impl From<toml::value::Table> for RustGeneratorConfig {
127 fn from(value: toml::value::Table) -> Self {
128 use serde::Deserialize;
129 match RustGeneratorConfig::deserialize(value) {
130 Ok(config) => config,
131 Err(e) => {
132 error!(
133 "Failed to parse Rust generator config: {}. Using default configuration.",
134 e
135 );
136 Self::default()
137 }
138 }
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 fn table(toml_str: &str) -> toml::value::Table {
147 toml::from_str::<toml::value::Table>(toml_str).unwrap()
148 }
149
150 #[test]
151 fn default_config_has_no_extra_derives() {
152 let config = RustGeneratorConfig::default();
153 assert!(config.crate_name.is_none());
154 assert!(config.extra_derives.is_none());
155 }
156
157 #[test]
158 fn deserialize_empty_table() {
159 let config = RustGeneratorConfig::from(toml::value::Table::new());
160 assert!(config.crate_name.is_none());
161 assert!(config.extra_derives.is_none());
162 }
163
164 #[test]
165 fn deserialize_crate_name_only() {
166 let config = RustGeneratorConfig::from(table(r#"crate_name = "my-sdk""#));
167 assert_eq!(config.crate_name.as_deref(), Some("my-sdk"));
168 assert!(config.extra_derives.is_none());
169 }
170
171 #[test]
172 fn deserialize_extra_derives_structs() {
173 let config = RustGeneratorConfig::from(table(
174 r#"
175 [extra_derives.structs]
176 derives = ["PartialEq", "Hash"]
177 "#,
178 ));
179 let extra = config.extra_derives.unwrap();
180 let structs = extra.structs.unwrap();
181 assert_eq!(structs.derives, vec!["PartialEq", "Hash"]);
182 assert!(structs.dependencies.is_empty());
183 assert!(extra.enums.is_none());
184 assert!(extra.unions.is_none());
185 assert!(extra.response_structs.is_none());
186 }
187
188 #[test]
189 fn deserialize_extra_derives_all_kinds() {
190 let config = RustGeneratorConfig::from(table(
191 r#"
192 [extra_derives.structs]
193 derives = ["PartialEq"]
194 [extra_derives.enums]
195 derives = ["Hash"]
196 [extra_derives.unions]
197 derives = ["Clone"]
198 [extra_derives.response_structs]
199 derives = ["PartialEq", "Eq"]
200 "#,
201 ));
202 let extra = config.extra_derives.unwrap();
203 assert_eq!(extra.structs.unwrap().derives, vec!["PartialEq"]);
204 assert_eq!(extra.enums.unwrap().derives, vec!["Hash"]);
205 assert_eq!(extra.unions.unwrap().derives, vec!["Clone"]);
206 assert_eq!(
207 extra.response_structs.unwrap().derives,
208 vec!["PartialEq", "Eq"]
209 );
210 }
211
212 #[test]
213 fn deserialize_extra_derives_with_dependencies() {
214 let config = RustGeneratorConfig::from(table(
215 r#"
216 [extra_derives.structs]
217 derives = ["utoipa::ToSchema"]
218 [extra_derives.structs.dependencies]
219 utoipa = '{ version = "5", features = ["openapi_extensions"] }'
220 "#,
221 ));
222 let structs = config.extra_derives.unwrap().structs.unwrap();
223 assert_eq!(structs.derives, vec!["utoipa::ToSchema"]);
224 assert_eq!(
225 structs.dependencies.get("utoipa").unwrap(),
226 r#"{ version = "5", features = ["openapi_extensions"] }"#
227 );
228 }
229
230 #[test]
231 fn all_dependencies_merges_across_kinds() {
232 let extra = ExtraDerivesConfig {
233 structs: Some(ExtraDeriveConfig {
234 derives: vec!["utoipa::ToSchema".into()],
235 dependencies: BTreeMap::from([
236 ("utoipa".into(), r#"{ version = "5" }"#.into()),
237 ("shared".into(), "\"1.0\"".into()),
238 ]),
239 }),
240 enums: Some(ExtraDeriveConfig {
241 derives: vec!["strum::Display".into()],
242 dependencies: BTreeMap::from([
243 ("strum".into(), r#"{ version = "0.26" }"#.into()),
244 ("shared".into(), "\"2.0\"".into()),
245 ]),
246 }),
247 unions: None,
248 response_structs: None,
249 per_type: None,
250 };
251 let deps = extra.all_dependencies();
252 assert_eq!(deps.len(), 3);
253 assert_eq!(deps["shared"], "\"1.0\"");
254 assert!(deps.contains_key("utoipa"));
255 assert!(deps.contains_key("strum"));
256 }
257
258 #[test]
259 fn all_dependencies_empty_when_no_extra() {
260 let extra = ExtraDerivesConfig::default();
261 assert!(extra.all_dependencies().is_empty());
262 }
263
264 #[test]
265 fn invalid_config_falls_back_to_default() {
266 let mut t = toml::value::Table::new();
267 t.insert(
268 "extra_derives".into(),
269 toml::Value::String("invalid".into()),
270 );
271 let config = RustGeneratorConfig::from(t);
272 assert!(config.crate_name.is_none());
273 assert!(config.extra_derives.is_none());
274 }
275
276 #[test]
277 fn package_name_alias_resolves_to_crate_name() {
278 let config = RustGeneratorConfig::from(table(r#"package_name = "my-api""#));
279 assert_eq!(config.crate_name.as_deref(), Some("my-api"));
280 }
281
282 #[test]
283 fn workspace_deps_mode_explicit() {
284 let config = RustGeneratorConfig::from(table(r#"workspace_deps = "explicit""#));
285 assert_eq!(
286 config.workspace_deps,
287 Some(super::WorkspaceDepsMode::Explicit)
288 );
289 }
290
291 #[test]
292 fn workspace_deps_mode_workspace_version() {
293 let config = RustGeneratorConfig::from(table(r#"workspace_deps = "workspace_version""#));
294 assert_eq!(
295 config.workspace_deps,
296 Some(super::WorkspaceDepsMode::WorkspaceVersion)
297 );
298 }
299
300 #[test]
301 fn workspace_deps_mode_full() {
302 let config = RustGeneratorConfig::from(table(r#"workspace_deps = "full""#));
303 assert_eq!(config.workspace_deps, Some(super::WorkspaceDepsMode::Full));
304 }
305
306 #[test]
307 fn per_type_extra_derives() {
308 let config = RustGeneratorConfig::from(table(
309 r#"
310 [extra_derives.per_type.PaymentMethod]
311 derives = ["Hash", "PartialEq"]
312
313 [extra_derives.per_type.PaymentMethod.dependencies]
314 dummy = '"1.0"'
315 "#,
316 ));
317 let extra = config.extra_derives.unwrap();
318 let per_type = extra.per_type.unwrap();
319 let pm = per_type.get("PaymentMethod").unwrap();
320 assert_eq!(pm.derives, vec!["Hash", "PartialEq"]);
321 assert_eq!(pm.dependencies.get("dummy").unwrap(), "\"1.0\"");
322 }
323
324 #[test]
325 fn all_dependencies_includes_per_type() {
326 let extra = ExtraDerivesConfig {
327 structs: Some(ExtraDeriveConfig {
328 derives: vec!["utoipa::ToSchema".into()],
329 dependencies: BTreeMap::from([("utoipa".into(), r#"{ version = "5" }"#.into())]),
330 }),
331 enums: None,
332 unions: None,
333 response_structs: None,
334 per_type: Some(BTreeMap::from([(
335 "MyType".into(),
336 ExtraDeriveConfig {
337 derives: vec!["Hash".into()],
338 dependencies: BTreeMap::from([("custom".into(), "\"2.0\"".into())]),
339 },
340 )])),
341 };
342 let deps = extra.all_dependencies();
343 assert_eq!(deps.len(), 2);
344 assert!(deps.contains_key("utoipa"));
345 assert!(deps.contains_key("custom"));
346 }
347
348 #[test]
349 fn all_dependencies_does_not_auto_infer() {
350 let extra = ExtraDerivesConfig {
351 structs: Some(ExtraDeriveConfig {
352 derives: vec!["utoipa::ToSchema".into(), "Clone".into()],
353 dependencies: BTreeMap::new(),
354 }),
355 enums: Some(ExtraDeriveConfig {
356 derives: vec!["strum::Display".into()],
357 dependencies: BTreeMap::new(),
358 }),
359 unions: None,
360 response_structs: None,
361 per_type: None,
362 };
363 let deps = extra.all_dependencies();
364 assert!(deps.is_empty());
365 }
366
367 #[test]
368 fn warn_missing_dependencies_does_not_panic() {
369 let extra = ExtraDerivesConfig {
370 structs: Some(ExtraDeriveConfig {
371 derives: vec!["utoipa::ToSchema".into()],
372 dependencies: BTreeMap::from([("utoipa".into(), r#"{ version = "5" }"#.into())]),
373 }),
374 enums: Some(ExtraDeriveConfig {
375 derives: vec!["strum::Display".into()],
376 dependencies: BTreeMap::new(),
377 }),
378 unions: None,
379 response_structs: None,
380 per_type: None,
381 };
382 extra.warn_missing_dependencies();
383 }
384}