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
60/// How dependencies are rendered in the generated Cargo.toml.
61#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
62#[serde(rename_all = "snake_case")]
63pub enum WorkspaceDepsMode {
64    /// Version and features specified inline (default).
65    #[default]
66    Explicit,
67    /// `dep = { workspace = true, features = [...] }` — version from workspace, features explicit.
68    WorkspaceVersion,
69    /// `dep.workspace = true` — everything from workspace.
70    Full,
71}
72
73/// Base configuration for Rust generators.
74#[derive(Debug, Clone, Default, Serialize, Deserialize)]
75pub struct RustGeneratorConfig {
76    #[serde(default, alias = "package_name")]
77    pub crate_name: Option<String>,
78    #[serde(default)]
79    pub extra_derives: Option<ExtraDerivesConfig>,
80    #[serde(default)]
81    pub workspace_mode: Option<bool>,
82    #[serde(default)]
83    pub workspace_deps: Option<WorkspaceDepsMode>,
84}
85
86impl From<toml::value::Table> for RustGeneratorConfig {
87    fn from(value: toml::value::Table) -> Self {
88        use serde::Deserialize;
89        match RustGeneratorConfig::deserialize(value) {
90            Ok(config) => config,
91            Err(e) => {
92                error!(
93                    "Failed to parse Rust generator config: {}. Using default configuration.",
94                    e
95                );
96                Self::default()
97            }
98        }
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    fn table(toml_str: &str) -> toml::value::Table {
107        toml::from_str::<toml::value::Table>(toml_str).unwrap()
108    }
109
110    #[test]
111    fn default_config_has_no_extra_derives() {
112        let config = RustGeneratorConfig::default();
113        assert!(config.crate_name.is_none());
114        assert!(config.extra_derives.is_none());
115    }
116
117    #[test]
118    fn deserialize_empty_table() {
119        let config = RustGeneratorConfig::from(toml::value::Table::new());
120        assert!(config.crate_name.is_none());
121        assert!(config.extra_derives.is_none());
122    }
123
124    #[test]
125    fn deserialize_crate_name_only() {
126        let config = RustGeneratorConfig::from(table(r#"crate_name = "my-sdk""#));
127        assert_eq!(config.crate_name.as_deref(), Some("my-sdk"));
128        assert!(config.extra_derives.is_none());
129    }
130
131    #[test]
132    fn deserialize_extra_derives_structs() {
133        let config = RustGeneratorConfig::from(table(
134            r#"
135            [extra_derives.structs]
136            derives = ["PartialEq", "Hash"]
137            "#,
138        ));
139        let extra = config.extra_derives.unwrap();
140        let structs = extra.structs.unwrap();
141        assert_eq!(structs.derives, vec!["PartialEq", "Hash"]);
142        assert!(structs.dependencies.is_empty());
143        assert!(extra.enums.is_none());
144        assert!(extra.unions.is_none());
145        assert!(extra.response_structs.is_none());
146    }
147
148    #[test]
149    fn deserialize_extra_derives_all_kinds() {
150        let config = RustGeneratorConfig::from(table(
151            r#"
152            [extra_derives.structs]
153            derives = ["PartialEq"]
154            [extra_derives.enums]
155            derives = ["Hash"]
156            [extra_derives.unions]
157            derives = ["Clone"]
158            [extra_derives.response_structs]
159            derives = ["PartialEq", "Eq"]
160            "#,
161        ));
162        let extra = config.extra_derives.unwrap();
163        assert_eq!(extra.structs.unwrap().derives, vec!["PartialEq"]);
164        assert_eq!(extra.enums.unwrap().derives, vec!["Hash"]);
165        assert_eq!(extra.unions.unwrap().derives, vec!["Clone"]);
166        assert_eq!(
167            extra.response_structs.unwrap().derives,
168            vec!["PartialEq", "Eq"]
169        );
170    }
171
172    #[test]
173    fn deserialize_extra_derives_with_dependencies() {
174        let config = RustGeneratorConfig::from(table(
175            r#"
176            [extra_derives.structs]
177            derives = ["utoipa::ToSchema"]
178            [extra_derives.structs.dependencies]
179            utoipa = '{ version = "5", features = ["openapi_extensions"] }'
180            "#,
181        ));
182        let structs = config.extra_derives.unwrap().structs.unwrap();
183        assert_eq!(structs.derives, vec!["utoipa::ToSchema"]);
184        assert_eq!(
185            structs.dependencies.get("utoipa").unwrap(),
186            r#"{ version = "5", features = ["openapi_extensions"] }"#
187        );
188    }
189
190    #[test]
191    fn all_dependencies_merges_across_kinds() {
192        let extra = ExtraDerivesConfig {
193            structs: Some(ExtraDeriveConfig {
194                derives: vec!["utoipa::ToSchema".into()],
195                dependencies: BTreeMap::from([
196                    ("utoipa".into(), r#"{ version = "5" }"#.into()),
197                    ("shared".into(), "\"1.0\"".into()),
198                ]),
199            }),
200            enums: Some(ExtraDeriveConfig {
201                derives: vec!["strum::Display".into()],
202                dependencies: BTreeMap::from([
203                    ("strum".into(), r#"{ version = "0.26" }"#.into()),
204                    ("shared".into(), "\"2.0\"".into()),
205                ]),
206            }),
207            unions: None,
208            response_structs: None,
209            per_type: None,
210        };
211        let deps = extra.all_dependencies();
212        assert_eq!(deps.len(), 3);
213        assert_eq!(deps["shared"], "\"1.0\"");
214        assert!(deps.contains_key("utoipa"));
215        assert!(deps.contains_key("strum"));
216    }
217
218    #[test]
219    fn all_dependencies_empty_when_no_extra() {
220        let extra = ExtraDerivesConfig::default();
221        assert!(extra.all_dependencies().is_empty());
222    }
223
224    #[test]
225    fn invalid_config_falls_back_to_default() {
226        let mut t = toml::value::Table::new();
227        t.insert(
228            "extra_derives".into(),
229            toml::Value::String("invalid".into()),
230        );
231        let config = RustGeneratorConfig::from(t);
232        assert!(config.crate_name.is_none());
233        assert!(config.extra_derives.is_none());
234    }
235
236    #[test]
237    fn package_name_alias_resolves_to_crate_name() {
238        let config = RustGeneratorConfig::from(table(r#"package_name = "my-api""#));
239        assert_eq!(config.crate_name.as_deref(), Some("my-api"));
240    }
241
242    #[test]
243    fn workspace_deps_mode_explicit() {
244        let config = RustGeneratorConfig::from(table(r#"workspace_deps = "explicit""#));
245        assert_eq!(
246            config.workspace_deps,
247            Some(super::WorkspaceDepsMode::Explicit)
248        );
249    }
250
251    #[test]
252    fn workspace_deps_mode_workspace_version() {
253        let config = RustGeneratorConfig::from(table(r#"workspace_deps = "workspace_version""#));
254        assert_eq!(
255            config.workspace_deps,
256            Some(super::WorkspaceDepsMode::WorkspaceVersion)
257        );
258    }
259
260    #[test]
261    fn workspace_deps_mode_full() {
262        let config = RustGeneratorConfig::from(table(r#"workspace_deps = "full""#));
263        assert_eq!(config.workspace_deps, Some(super::WorkspaceDepsMode::Full));
264    }
265
266    #[test]
267    fn per_type_extra_derives() {
268        let config = RustGeneratorConfig::from(table(
269            r#"
270            [extra_derives.per_type.PaymentMethod]
271            derives = ["Hash", "PartialEq"]
272
273            [extra_derives.per_type.PaymentMethod.dependencies]
274            dummy = '"1.0"'
275            "#,
276        ));
277        let extra = config.extra_derives.unwrap();
278        let per_type = extra.per_type.unwrap();
279        let pm = per_type.get("PaymentMethod").unwrap();
280        assert_eq!(pm.derives, vec!["Hash", "PartialEq"]);
281        assert_eq!(pm.dependencies.get("dummy").unwrap(), "\"1.0\"");
282    }
283
284    #[test]
285    fn all_dependencies_includes_per_type() {
286        let extra = ExtraDerivesConfig {
287            structs: Some(ExtraDeriveConfig {
288                derives: vec!["utoipa::ToSchema".into()],
289                dependencies: BTreeMap::from([("utoipa".into(), r#"{ version = "5" }"#.into())]),
290            }),
291            enums: None,
292            unions: None,
293            response_structs: None,
294            per_type: Some(BTreeMap::from([(
295                "MyType".into(),
296                ExtraDeriveConfig {
297                    derives: vec!["Hash".into()],
298                    dependencies: BTreeMap::from([("custom".into(), "\"2.0\"".into())]),
299                },
300            )])),
301        };
302        let deps = extra.all_dependencies();
303        assert_eq!(deps.len(), 2);
304        assert!(deps.contains_key("utoipa"));
305        assert!(deps.contains_key("custom"));
306    }
307}