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