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