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 UtoipaConfig {
116 #[serde(default)]
117 pub enabled: bool,
118 #[serde(default)]
119 pub dependency: Option<String>,
120}
121
122#[derive(Debug, Clone, Default, Serialize, Deserialize)]
124pub struct RustGeneratorConfig {
125 #[serde(default, alias = "package_name")]
126 pub crate_name: Option<String>,
127 #[serde(default)]
128 pub extra_derives: Option<ExtraDerivesConfig>,
129 #[serde(default)]
130 pub workspace_mode: Option<bool>,
131 #[serde(default)]
132 pub workspace_deps: Option<WorkspaceDepsMode>,
133 #[serde(default)]
134 pub utoipa: Option<UtoipaConfig>,
135 #[serde(default)]
136 pub aioduct: Option<crate::generators::rust::aioduct::config::AioductFeatureConfig>,
137}
138
139impl From<toml::value::Table> for RustGeneratorConfig {
140 fn from(value: toml::value::Table) -> Self {
141 use serde::Deserialize;
142 match RustGeneratorConfig::deserialize(value) {
143 Ok(config) => config,
144 Err(e) => {
145 error!(
146 "Failed to parse Rust generator config: {}. Using default configuration.",
147 e
148 );
149 Self::default()
150 }
151 }
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 fn table(toml_str: &str) -> toml::value::Table {
160 toml::from_str::<toml::value::Table>(toml_str).unwrap()
161 }
162
163 #[test]
164 fn default_config_has_no_extra_derives() {
165 let config = RustGeneratorConfig::default();
166 assert!(config.crate_name.is_none());
167 assert!(config.extra_derives.is_none());
168 }
169
170 #[test]
171 fn deserialize_empty_table() {
172 let config = RustGeneratorConfig::from(toml::value::Table::new());
173 assert!(config.crate_name.is_none());
174 assert!(config.extra_derives.is_none());
175 }
176
177 #[test]
178 fn deserialize_crate_name_only() {
179 let config = RustGeneratorConfig::from(table(r#"crate_name = "my-sdk""#));
180 assert_eq!(config.crate_name.as_deref(), Some("my-sdk"));
181 assert!(config.extra_derives.is_none());
182 }
183
184 #[test]
185 fn deserialize_extra_derives_structs() {
186 let config = RustGeneratorConfig::from(table(
187 r#"
188 [extra_derives.structs]
189 derives = ["PartialEq", "Hash"]
190 "#,
191 ));
192 let extra = config.extra_derives.unwrap();
193 let structs = extra.structs.unwrap();
194 assert_eq!(structs.derives, vec!["PartialEq", "Hash"]);
195 assert!(structs.dependencies.is_empty());
196 assert!(extra.enums.is_none());
197 assert!(extra.unions.is_none());
198 assert!(extra.response_structs.is_none());
199 }
200
201 #[test]
202 fn deserialize_extra_derives_all_kinds() {
203 let config = RustGeneratorConfig::from(table(
204 r#"
205 [extra_derives.structs]
206 derives = ["PartialEq"]
207 [extra_derives.enums]
208 derives = ["Hash"]
209 [extra_derives.unions]
210 derives = ["Clone"]
211 [extra_derives.response_structs]
212 derives = ["PartialEq", "Eq"]
213 "#,
214 ));
215 let extra = config.extra_derives.unwrap();
216 assert_eq!(extra.structs.unwrap().derives, vec!["PartialEq"]);
217 assert_eq!(extra.enums.unwrap().derives, vec!["Hash"]);
218 assert_eq!(extra.unions.unwrap().derives, vec!["Clone"]);
219 assert_eq!(
220 extra.response_structs.unwrap().derives,
221 vec!["PartialEq", "Eq"]
222 );
223 }
224
225 #[test]
226 fn deserialize_extra_derives_with_dependencies() {
227 let config = RustGeneratorConfig::from(table(
228 r#"
229 [extra_derives.structs]
230 derives = ["utoipa::ToSchema"]
231 [extra_derives.structs.dependencies]
232 utoipa = '{ version = "5", features = ["openapi_extensions"] }'
233 "#,
234 ));
235 let structs = config.extra_derives.unwrap().structs.unwrap();
236 assert_eq!(structs.derives, vec!["utoipa::ToSchema"]);
237 assert_eq!(
238 structs.dependencies.get("utoipa").unwrap(),
239 r#"{ version = "5", features = ["openapi_extensions"] }"#
240 );
241 }
242
243 #[test]
244 fn all_dependencies_merges_across_kinds() {
245 let extra = ExtraDerivesConfig {
246 structs: Some(ExtraDeriveConfig {
247 derives: vec!["utoipa::ToSchema".into()],
248 dependencies: BTreeMap::from([
249 ("utoipa".into(), r#"{ version = "5" }"#.into()),
250 ("shared".into(), "\"1.0\"".into()),
251 ]),
252 }),
253 enums: Some(ExtraDeriveConfig {
254 derives: vec!["strum::Display".into()],
255 dependencies: BTreeMap::from([
256 ("strum".into(), r#"{ version = "0.26" }"#.into()),
257 ("shared".into(), "\"2.0\"".into()),
258 ]),
259 }),
260 unions: None,
261 response_structs: None,
262 per_type: None,
263 };
264 let deps = extra.all_dependencies();
265 assert_eq!(deps.len(), 3);
266 assert_eq!(deps["shared"], "\"1.0\"");
267 assert!(deps.contains_key("utoipa"));
268 assert!(deps.contains_key("strum"));
269 }
270
271 #[test]
272 fn all_dependencies_empty_when_no_extra() {
273 let extra = ExtraDerivesConfig::default();
274 assert!(extra.all_dependencies().is_empty());
275 }
276
277 #[test]
278 fn invalid_config_falls_back_to_default() {
279 let mut t = toml::value::Table::new();
280 t.insert(
281 "extra_derives".into(),
282 toml::Value::String("invalid".into()),
283 );
284 let config = RustGeneratorConfig::from(t);
285 assert!(config.crate_name.is_none());
286 assert!(config.extra_derives.is_none());
287 }
288
289 #[test]
290 fn package_name_alias_resolves_to_crate_name() {
291 let config = RustGeneratorConfig::from(table(r#"package_name = "my-api""#));
292 assert_eq!(config.crate_name.as_deref(), Some("my-api"));
293 }
294
295 #[test]
296 fn workspace_deps_mode_explicit() {
297 let config = RustGeneratorConfig::from(table(r#"workspace_deps = "explicit""#));
298 assert_eq!(
299 config.workspace_deps,
300 Some(super::WorkspaceDepsMode::Explicit)
301 );
302 }
303
304 #[test]
305 fn workspace_deps_mode_workspace_version() {
306 let config = RustGeneratorConfig::from(table(r#"workspace_deps = "workspace_version""#));
307 assert_eq!(
308 config.workspace_deps,
309 Some(super::WorkspaceDepsMode::WorkspaceVersion)
310 );
311 }
312
313 #[test]
314 fn workspace_deps_mode_full() {
315 let config = RustGeneratorConfig::from(table(r#"workspace_deps = "full""#));
316 assert_eq!(config.workspace_deps, Some(super::WorkspaceDepsMode::Full));
317 }
318
319 #[test]
320 fn per_type_extra_derives() {
321 let config = RustGeneratorConfig::from(table(
322 r#"
323 [extra_derives.per_type.PaymentMethod]
324 derives = ["Hash", "PartialEq"]
325
326 [extra_derives.per_type.PaymentMethod.dependencies]
327 dummy = '"1.0"'
328 "#,
329 ));
330 let extra = config.extra_derives.unwrap();
331 let per_type = extra.per_type.unwrap();
332 let pm = per_type.get("PaymentMethod").unwrap();
333 assert_eq!(pm.derives, vec!["Hash", "PartialEq"]);
334 assert_eq!(pm.dependencies.get("dummy").unwrap(), "\"1.0\"");
335 }
336
337 #[test]
338 fn all_dependencies_includes_per_type() {
339 let extra = ExtraDerivesConfig {
340 structs: Some(ExtraDeriveConfig {
341 derives: vec!["utoipa::ToSchema".into()],
342 dependencies: BTreeMap::from([("utoipa".into(), r#"{ version = "5" }"#.into())]),
343 }),
344 enums: None,
345 unions: None,
346 response_structs: None,
347 per_type: Some(BTreeMap::from([(
348 "MyType".into(),
349 ExtraDeriveConfig {
350 derives: vec!["Hash".into()],
351 dependencies: BTreeMap::from([("custom".into(), "\"2.0\"".into())]),
352 },
353 )])),
354 };
355 let deps = extra.all_dependencies();
356 assert_eq!(deps.len(), 2);
357 assert!(deps.contains_key("utoipa"));
358 assert!(deps.contains_key("custom"));
359 }
360
361 #[test]
362 fn all_dependencies_does_not_auto_infer() {
363 let extra = ExtraDerivesConfig {
364 structs: Some(ExtraDeriveConfig {
365 derives: vec!["utoipa::ToSchema".into(), "Clone".into()],
366 dependencies: BTreeMap::new(),
367 }),
368 enums: Some(ExtraDeriveConfig {
369 derives: vec!["strum::Display".into()],
370 dependencies: BTreeMap::new(),
371 }),
372 unions: None,
373 response_structs: None,
374 per_type: None,
375 };
376 let deps = extra.all_dependencies();
377 assert!(deps.is_empty());
378 }
379
380 #[test]
381 fn warn_missing_dependencies_does_not_panic() {
382 let extra = ExtraDerivesConfig {
383 structs: Some(ExtraDeriveConfig {
384 derives: vec!["utoipa::ToSchema".into()],
385 dependencies: BTreeMap::from([("utoipa".into(), r#"{ version = "5" }"#.into())]),
386 }),
387 enums: Some(ExtraDeriveConfig {
388 derives: vec!["strum::Display".into()],
389 dependencies: BTreeMap::new(),
390 }),
391 unions: None,
392 response_structs: None,
393 per_type: None,
394 };
395 extra.warn_missing_dependencies();
396 }
397}