1use std::fmt;
2use std::fs;
3use std::path::Path;
4
5use indexmap::IndexMap;
6use serde::de;
7use serde::{Deserialize, Deserializer};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum ToolSetting {
15 Named(String),
16 Disabled,
17}
18
19impl ToolSetting {
20 pub fn resolve<'a>(setting: Option<&'a Self>, default: &'a str) -> Option<&'a str> {
22 match setting {
23 None => Some(default),
24 Some(ToolSetting::Named(s)) => Some(s.as_str()),
25 Some(ToolSetting::Disabled) => None,
26 }
27 }
28}
29
30impl<'de> Deserialize<'de> for ToolSetting {
31 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
32 where
33 D: Deserializer<'de>,
34 {
35 let value = serde_json::Value::deserialize(deserializer).map_err(de::Error::custom)?;
36 match value {
37 serde_json::Value::String(s) => Ok(ToolSetting::Named(s)),
38 serde_json::Value::Bool(false) => Ok(ToolSetting::Disabled),
39 serde_json::Value::Bool(true) => {
40 Err(de::Error::custom(
42 "use `false` to disable or a string to name the tool; `true` is treated as default (omit the field)",
43 ))
44 }
45 _ => Err(de::Error::custom(
46 "expected a tool name string or `false` to disable",
47 )),
48 }
49 }
50}
51
52#[derive(Debug, Clone)]
54pub struct OagConfig {
55 pub input: String,
56 pub naming: NamingConfig,
57 pub generators: IndexMap<GeneratorId, GeneratorConfig>,
58}
59
60impl Default for OagConfig {
61 fn default() -> Self {
62 Self {
63 input: "openapi.yaml".to_string(),
64 naming: NamingConfig::default(),
65 generators: IndexMap::new(),
66 }
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
72pub enum GeneratorId {
73 NodeClient,
74 ReactSwrClient,
75 FastapiServer,
76}
77
78impl GeneratorId {
79 pub fn as_str(&self) -> &'static str {
80 match self {
81 GeneratorId::NodeClient => "node-client",
82 GeneratorId::ReactSwrClient => "react-swr-client",
83 GeneratorId::FastapiServer => "fastapi-server",
84 }
85 }
86}
87
88impl fmt::Display for GeneratorId {
89 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90 write!(f, "{}", self.as_str())
91 }
92}
93
94impl<'de> Deserialize<'de> for GeneratorId {
95 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
96 where
97 D: Deserializer<'de>,
98 {
99 let s = String::deserialize(deserializer)?;
100 match s.as_str() {
101 "node-client" => Ok(GeneratorId::NodeClient),
102 "react-swr-client" => Ok(GeneratorId::ReactSwrClient),
103 "fastapi-server" => Ok(GeneratorId::FastapiServer),
104 other => Err(de::Error::unknown_variant(
105 other,
106 &["node-client", "react-swr-client", "fastapi-server"],
107 )),
108 }
109 }
110}
111
112#[derive(Debug, Clone, Deserialize)]
114#[serde(default)]
115pub struct GeneratorConfig {
116 pub output: String,
117 pub layout: OutputLayout,
118 pub split_by: Option<SplitBy>,
119 pub base_url: Option<String>,
120 pub no_jsdoc: Option<bool>,
121 pub scaffold: Option<serde_json::Value>,
123}
124
125impl Default for GeneratorConfig {
126 fn default() -> Self {
127 Self {
128 output: "src/generated".to_string(),
129 layout: OutputLayout::Modular,
130 split_by: None,
131 base_url: None,
132 no_jsdoc: None,
133 scaffold: None,
134 }
135 }
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
140#[serde(rename_all = "snake_case")]
141pub enum OutputLayout {
142 Bundled,
144 Modular,
146 Split,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
152#[serde(rename_all = "snake_case")]
153pub enum SplitBy {
154 Operation,
156 Tag,
158 Route,
160}
161
162#[derive(Debug, Clone, Deserialize)]
164#[serde(default)]
165pub struct NamingConfig {
166 pub strategy: NamingStrategy,
167 #[serde(default)]
169 pub aliases: IndexMap<String, String>,
170}
171
172impl Default for NamingConfig {
173 fn default() -> Self {
174 Self {
175 strategy: NamingStrategy::UseOperationId,
176 aliases: IndexMap::new(),
177 }
178 }
179}
180
181#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
183#[serde(rename_all = "snake_case")]
184pub enum NamingStrategy {
185 #[default]
186 UseOperationId,
187 UseRouteBased,
188}
189
190#[derive(Deserialize)]
197struct LegacyConfig {
198 #[serde(default = "default_input")]
199 input: String,
200 #[serde(default = "default_output")]
201 output: String,
202 #[serde(default)]
203 target: LegacyTargetKind,
204 #[serde(default)]
205 naming: NamingConfig,
206 #[serde(default)]
207 output_options: LegacyOutputOptions,
208 #[serde(default)]
209 client: LegacyClientConfig,
210}
211
212fn default_input() -> String {
213 "openapi.yaml".to_string()
214}
215fn default_output() -> String {
216 "src/generated".to_string()
217}
218
219#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
220#[serde(rename_all = "snake_case")]
221enum LegacyTargetKind {
222 Typescript,
223 React,
224 #[default]
225 All,
226}
227
228#[derive(Debug, Clone, Deserialize)]
229#[serde(default)]
230struct LegacyOutputOptions {
231 layout: LegacyOutputLayout,
232 index: bool,
233 biome: bool,
234 tsdown: bool,
235 package_name: Option<String>,
236 repository: Option<String>,
237}
238
239impl Default for LegacyOutputOptions {
240 fn default() -> Self {
241 Self {
242 layout: LegacyOutputLayout::Single,
243 index: true,
244 biome: true,
245 tsdown: true,
246 package_name: None,
247 repository: None,
248 }
249 }
250}
251
252#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
253#[serde(rename_all = "snake_case")]
254enum LegacyOutputLayout {
255 #[default]
256 Single,
257 Split,
258}
259
260#[derive(Debug, Clone, Default, Deserialize)]
261#[serde(default)]
262struct LegacyClientConfig {
263 base_url: Option<String>,
264 no_jsdoc: bool,
265}
266
267#[derive(Deserialize)]
269struct NewConfig {
270 #[serde(default = "default_input")]
271 input: String,
272 #[serde(default)]
273 naming: NamingConfig,
274 generators: IndexMap<GeneratorId, GeneratorConfig>,
275}
276
277impl<'de> Deserialize<'de> for OagConfig {
278 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
279 where
280 D: Deserializer<'de>,
281 {
282 let value = serde_json::Value::deserialize(deserializer).map_err(de::Error::custom)?;
284
285 if value.get("generators").is_some() {
287 let new_cfg: NewConfig = serde_json::from_value(value).map_err(de::Error::custom)?;
288 Ok(OagConfig {
289 input: new_cfg.input,
290 naming: new_cfg.naming,
291 generators: new_cfg.generators,
292 })
293 } else {
294 let legacy: LegacyConfig = serde_json::from_value(value).map_err(de::Error::custom)?;
296 Ok(convert_legacy(legacy))
297 }
298 }
299}
300
301fn convert_legacy(legacy: LegacyConfig) -> OagConfig {
302 let scaffold = Some(serde_json::json!({
303 "package_name": legacy.output_options.package_name,
304 "repository": legacy.output_options.repository,
305 "index": legacy.output_options.index,
306 "formatter": if legacy.output_options.biome { serde_json::Value::String("biome".into()) } else { serde_json::Value::Bool(false) },
307 "bundler": if legacy.output_options.tsdown { serde_json::Value::String("tsdown".into()) } else { serde_json::Value::Bool(false) },
308 "test_runner": serde_json::Value::String("vitest".into()),
309 }));
310
311 let base_gen_config = |output: String| GeneratorConfig {
312 output,
313 layout: OutputLayout::Modular,
314 split_by: None,
315 base_url: legacy.client.base_url.clone(),
316 no_jsdoc: Some(legacy.client.no_jsdoc),
317 scaffold: scaffold.clone(),
318 };
319
320 let mut generators = IndexMap::new();
321
322 match (&legacy.target, &legacy.output_options.layout) {
323 (LegacyTargetKind::Typescript, _) => {
324 generators.insert(
325 GeneratorId::NodeClient,
326 base_gen_config(legacy.output.clone()),
327 );
328 }
329 (LegacyTargetKind::React, _) => {
330 generators.insert(
331 GeneratorId::ReactSwrClient,
332 base_gen_config(legacy.output.clone()),
333 );
334 }
335 (LegacyTargetKind::All, LegacyOutputLayout::Single) => {
336 generators.insert(
340 GeneratorId::ReactSwrClient,
341 base_gen_config(legacy.output.clone()),
342 );
343 }
344 (LegacyTargetKind::All, LegacyOutputLayout::Split) => {
345 let ts_output = format!("{}/typescript", legacy.output);
346 let react_output = format!("{}/react", legacy.output);
347 generators.insert(GeneratorId::NodeClient, base_gen_config(ts_output));
348 generators.insert(GeneratorId::ReactSwrClient, base_gen_config(react_output));
349 }
350 }
351
352 OagConfig {
353 input: legacy.input,
354 naming: legacy.naming,
355 generators,
356 }
357}
358
359pub const CONFIG_FILE_NAME: &str = ".urmzd.oag.yaml";
361
362pub fn load_config(path: &Path) -> Result<Option<OagConfig>, String> {
364 if !path.exists() {
365 return Ok(None);
366 }
367 let content = fs::read_to_string(path)
368 .map_err(|e| format!("failed to read config {}: {}", path.display(), e))?;
369
370 let yaml_value: serde_json::Value = serde_yaml_ng::from_str(&content)
372 .map_err(|e| format!("failed to parse config {}: {}", path.display(), e))?;
373 let config: OagConfig = serde_json::from_value(yaml_value)
374 .map_err(|e| format!("failed to parse config {}: {}", path.display(), e))?;
375 Ok(Some(config))
376}
377
378pub fn default_config_content() -> &'static str {
380 r#"# oag configuration — https://github.com/urmzd/openapi-generator
381input: openapi.yaml
382
383naming:
384 strategy: use_operation_id # use_operation_id | use_route_based
385 aliases: {}
386 # createChatCompletion: chat # operationId → custom name
387 # listModels: models
388
389generators:
390 node-client:
391 output: src/generated/node
392 layout: modular # bundled | modular | split
393 # split_by: tag # operation | tag | route (only for split layout)
394 # base_url: https://api.example.com
395 # no_jsdoc: false
396 scaffold:
397 # package_name: my-api-client
398 # repository: https://github.com/you/your-repo
399 # existing_repo: false # set to true to skip all scaffold files (package.json, tsconfig, etc.)
400 formatter: biome # biome | false
401 test_runner: vitest # vitest | false
402 bundler: tsdown # tsdown | false
403
404 # react-swr-client:
405 # output: src/generated/react
406 # layout: modular
407 # scaffold:
408 # formatter: biome
409 # test_runner: vitest
410 # bundler: tsdown
411
412 # fastapi-server:
413 # output: src/generated/server
414 # layout: modular
415 # scaffold:
416 # formatter: ruff # ruff | false
417 # test_runner: pytest # pytest | false
418"#
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424
425 #[test]
426 fn test_default_config() {
427 let config = OagConfig::default();
428 assert_eq!(config.input, "openapi.yaml");
429 assert_eq!(config.naming.strategy, NamingStrategy::UseOperationId);
430 assert!(config.naming.aliases.is_empty());
431 assert!(config.generators.is_empty());
432 }
433
434 #[test]
435 fn test_parse_new_format() {
436 let yaml = r#"
437input: spec.yaml
438
439naming:
440 strategy: use_route_based
441 aliases:
442 createChatCompletion: chat
443
444generators:
445 node-client:
446 output: out/node
447 layout: modular
448 base_url: https://api.example.com
449 scaffold:
450 package_name: "@myorg/client"
451 formatter: biome
452 bundler: tsdown
453 react-swr-client:
454 output: out/react
455 layout: split
456 split_by: tag
457"#;
458 let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
459 let config: OagConfig = serde_json::from_value(value).unwrap();
460 assert_eq!(config.input, "spec.yaml");
461 assert_eq!(config.naming.strategy, NamingStrategy::UseRouteBased);
462 assert_eq!(config.generators.len(), 2);
463
464 let node = &config.generators[&GeneratorId::NodeClient];
465 assert_eq!(node.output, "out/node");
466 assert_eq!(node.layout, OutputLayout::Modular);
467 assert_eq!(node.base_url, Some("https://api.example.com".to_string()));
468 assert!(node.scaffold.is_some());
469 let scaffold = node.scaffold.as_ref().unwrap();
470 assert_eq!(scaffold["package_name"], "@myorg/client");
471 assert_eq!(scaffold["formatter"], "biome");
472 assert_eq!(scaffold["bundler"], "tsdown");
473
474 let react = &config.generators[&GeneratorId::ReactSwrClient];
475 assert_eq!(react.output, "out/react");
476 assert_eq!(react.layout, OutputLayout::Split);
477 assert_eq!(react.split_by, Some(SplitBy::Tag));
478 }
479
480 #[test]
481 fn test_parse_legacy_typescript() {
482 let yaml = r#"
483input: spec.yaml
484output: out
485target: typescript
486naming:
487 strategy: use_operation_id
488 aliases: {}
489output_options:
490 layout: single
491 biome: true
492 tsdown: true
493client:
494 base_url: https://api.example.com
495 no_jsdoc: true
496"#;
497 let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
498 let config: OagConfig = serde_json::from_value(value).unwrap();
499 assert_eq!(config.input, "spec.yaml");
500 assert_eq!(config.generators.len(), 1);
501 assert!(config.generators.contains_key(&GeneratorId::NodeClient));
502
503 let node_gen = &config.generators[&GeneratorId::NodeClient];
504 assert_eq!(node_gen.output, "out");
505 assert_eq!(
506 node_gen.base_url,
507 Some("https://api.example.com".to_string())
508 );
509 assert_eq!(node_gen.no_jsdoc, Some(true));
510 }
511
512 #[test]
513 fn test_parse_legacy_react() {
514 let yaml = r#"
515input: spec.yaml
516output: out
517target: react
518"#;
519 let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
520 let config: OagConfig = serde_json::from_value(value).unwrap();
521 assert_eq!(config.generators.len(), 1);
522 assert!(config.generators.contains_key(&GeneratorId::ReactSwrClient));
523 }
524
525 #[test]
526 fn test_parse_legacy_all_single() {
527 let yaml = r#"
528input: spec.yaml
529output: out
530target: all
531output_options:
532 layout: single
533"#;
534 let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
535 let config: OagConfig = serde_json::from_value(value).unwrap();
536 assert_eq!(config.generators.len(), 1);
538 assert!(config.generators.contains_key(&GeneratorId::ReactSwrClient));
539 }
540
541 #[test]
542 fn test_parse_legacy_all_split() {
543 let yaml = r#"
544input: spec.yaml
545output: out
546target: all
547output_options:
548 layout: split
549"#;
550 let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
551 let config: OagConfig = serde_json::from_value(value).unwrap();
552 assert_eq!(config.generators.len(), 2);
553 assert!(config.generators.contains_key(&GeneratorId::NodeClient));
554 assert!(config.generators.contains_key(&GeneratorId::ReactSwrClient));
555 assert_eq!(
556 config.generators[&GeneratorId::NodeClient].output,
557 "out/typescript"
558 );
559 assert_eq!(
560 config.generators[&GeneratorId::ReactSwrClient].output,
561 "out/react"
562 );
563 }
564
565 #[test]
566 fn test_tool_setting_resolve() {
567 assert_eq!(ToolSetting::resolve(None, "biome"), Some("biome"));
568 assert_eq!(
569 ToolSetting::resolve(Some(&ToolSetting::Named("ruff".into())), "biome"),
570 Some("ruff")
571 );
572 assert_eq!(
573 ToolSetting::resolve(Some(&ToolSetting::Disabled), "biome"),
574 None
575 );
576 }
577
578 #[test]
579 fn test_tool_setting_deserialize() {
580 let named: ToolSetting = serde_json::from_value(serde_json::json!("biome")).unwrap();
581 assert_eq!(named, ToolSetting::Named("biome".into()));
582
583 let disabled: ToolSetting = serde_json::from_value(serde_json::json!(false)).unwrap();
584 assert_eq!(disabled, ToolSetting::Disabled);
585
586 let err = serde_json::from_value::<ToolSetting>(serde_json::json!(true));
587 assert!(err.is_err());
588 }
589
590 #[test]
591 fn test_parse_minimal_config() {
592 let yaml = "input: api.yaml\n";
593 let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
594 let config: OagConfig = serde_json::from_value(value).unwrap();
595 assert_eq!(config.input, "api.yaml");
596 assert_eq!(config.generators.len(), 1);
598 }
599}