Skip to main content

openapi_nexus/generators/rust/common/
config.rs

1//! Base configuration shared across all Rust generators.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6use tracing::error;
7
8/// Extra derives for a single type-kind (structs, enums, unions, or response structs).
9#[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/// Per-type-kind extra derive configuration.
18#[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    /// Collect all unique crate dependencies across every type-kind.
34    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    /// Warn about derives that reference external crates without a matching
60    /// entry in `dependencies`. Call this during codegen so users get feedback.
61    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/// How dependencies are rendered in the generated Cargo.toml.
101#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
102#[serde(rename_all = "snake_case")]
103pub enum WorkspaceDepsMode {
104    /// Version and features specified inline (default).
105    #[default]
106    Explicit,
107    /// `dep = { workspace = true, features = [...] }` — version from workspace, features explicit.
108    WorkspaceVersion,
109    /// `dep.workspace = true` — everything from workspace.
110    Full,
111}
112
113/// Utoipa integration configuration.
114#[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/// Base configuration for Rust generators.
123#[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}