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}
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}