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 source_dir: String,
124 pub scaffold: Option<serde_json::Value>,
126}
127
128impl Default for GeneratorConfig {
129 fn default() -> Self {
130 Self {
131 output: "src/generated".to_string(),
132 layout: OutputLayout::Modular,
133 split_by: None,
134 base_url: None,
135 no_jsdoc: None,
136 source_dir: "src".to_string(),
137 scaffold: None,
138 }
139 }
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
144#[serde(rename_all = "snake_case")]
145pub enum OutputLayout {
146 Bundled,
148 Modular,
150 Split,
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
156#[serde(rename_all = "snake_case")]
157pub enum SplitBy {
158 Operation,
160 Tag,
162 Route,
164}
165
166#[derive(Debug, Clone, Deserialize)]
168#[serde(default)]
169pub struct NamingConfig {
170 pub strategy: NamingStrategy,
171 #[serde(default)]
173 pub aliases: IndexMap<String, String>,
174}
175
176impl Default for NamingConfig {
177 fn default() -> Self {
178 Self {
179 strategy: NamingStrategy::UseOperationId,
180 aliases: IndexMap::new(),
181 }
182 }
183}
184
185#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
187#[serde(rename_all = "snake_case")]
188pub enum NamingStrategy {
189 #[default]
190 UseOperationId,
191 UseRouteBased,
192}
193
194#[derive(Deserialize)]
201struct LegacyConfig {
202 #[serde(default = "default_input")]
203 input: String,
204 #[serde(default = "default_output")]
205 output: String,
206 #[serde(default)]
207 target: LegacyTargetKind,
208 #[serde(default)]
209 naming: NamingConfig,
210 #[serde(default)]
211 output_options: LegacyOutputOptions,
212 #[serde(default)]
213 client: LegacyClientConfig,
214}
215
216fn default_input() -> String {
217 "openapi.yaml".to_string()
218}
219fn default_output() -> String {
220 "src/generated".to_string()
221}
222
223#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
224#[serde(rename_all = "snake_case")]
225enum LegacyTargetKind {
226 Typescript,
227 React,
228 #[default]
229 All,
230}
231
232#[derive(Debug, Clone, Deserialize)]
233#[serde(default)]
234struct LegacyOutputOptions {
235 layout: LegacyOutputLayout,
236 index: bool,
237 biome: bool,
238 tsdown: bool,
239 package_name: Option<String>,
240 repository: Option<String>,
241}
242
243impl Default for LegacyOutputOptions {
244 fn default() -> Self {
245 Self {
246 layout: LegacyOutputLayout::Single,
247 index: true,
248 biome: true,
249 tsdown: true,
250 package_name: None,
251 repository: None,
252 }
253 }
254}
255
256#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
257#[serde(rename_all = "snake_case")]
258enum LegacyOutputLayout {
259 #[default]
260 Single,
261 Split,
262}
263
264#[derive(Debug, Clone, Default, Deserialize)]
265#[serde(default)]
266struct LegacyClientConfig {
267 base_url: Option<String>,
268 no_jsdoc: bool,
269}
270
271#[derive(Deserialize)]
273struct NewConfig {
274 #[serde(default = "default_input")]
275 input: String,
276 #[serde(default)]
277 naming: NamingConfig,
278 generators: IndexMap<GeneratorId, GeneratorConfig>,
279}
280
281impl<'de> Deserialize<'de> for OagConfig {
282 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
283 where
284 D: Deserializer<'de>,
285 {
286 let value = serde_json::Value::deserialize(deserializer).map_err(de::Error::custom)?;
288
289 if value.get("generators").is_some() {
291 let new_cfg: NewConfig = serde_json::from_value(value).map_err(de::Error::custom)?;
292 Ok(OagConfig {
293 input: new_cfg.input,
294 naming: new_cfg.naming,
295 generators: new_cfg.generators,
296 })
297 } else {
298 let legacy: LegacyConfig = serde_json::from_value(value).map_err(de::Error::custom)?;
300 Ok(convert_legacy(legacy))
301 }
302 }
303}
304
305fn convert_legacy(legacy: LegacyConfig) -> OagConfig {
306 let scaffold = Some(serde_json::json!({
307 "package_name": legacy.output_options.package_name,
308 "repository": legacy.output_options.repository,
309 "index": legacy.output_options.index,
310 "formatter": if legacy.output_options.biome { serde_json::Value::String("biome".into()) } else { serde_json::Value::Bool(false) },
311 "bundler": if legacy.output_options.tsdown { serde_json::Value::String("tsdown".into()) } else { serde_json::Value::Bool(false) },
312 "test_runner": serde_json::Value::String("vitest".into()),
313 }));
314
315 let base_gen_config = |output: String| GeneratorConfig {
316 output,
317 layout: OutputLayout::Modular,
318 split_by: None,
319 base_url: legacy.client.base_url.clone(),
320 no_jsdoc: Some(legacy.client.no_jsdoc),
321 source_dir: "src".to_string(),
322 scaffold: scaffold.clone(),
323 };
324
325 let mut generators = IndexMap::new();
326
327 match (&legacy.target, &legacy.output_options.layout) {
328 (LegacyTargetKind::Typescript, _) => {
329 generators.insert(
330 GeneratorId::NodeClient,
331 base_gen_config(legacy.output.clone()),
332 );
333 }
334 (LegacyTargetKind::React, _) => {
335 generators.insert(
336 GeneratorId::ReactSwrClient,
337 base_gen_config(legacy.output.clone()),
338 );
339 }
340 (LegacyTargetKind::All, LegacyOutputLayout::Single) => {
341 generators.insert(
345 GeneratorId::ReactSwrClient,
346 base_gen_config(legacy.output.clone()),
347 );
348 }
349 (LegacyTargetKind::All, LegacyOutputLayout::Split) => {
350 let ts_output = format!("{}/typescript", legacy.output);
351 let react_output = format!("{}/react", legacy.output);
352 generators.insert(GeneratorId::NodeClient, base_gen_config(ts_output));
353 generators.insert(GeneratorId::ReactSwrClient, base_gen_config(react_output));
354 }
355 }
356
357 OagConfig {
358 input: legacy.input,
359 naming: legacy.naming,
360 generators,
361 }
362}
363
364pub const CONFIG_FILE_NAME: &str = ".urmzd.oag.yaml";
366
367pub fn load_config(path: &Path) -> Result<Option<OagConfig>, String> {
369 if !path.exists() {
370 return Ok(None);
371 }
372 let content = fs::read_to_string(path)
373 .map_err(|e| format!("failed to read config {}: {}", path.display(), e))?;
374
375 let yaml_value: serde_json::Value = serde_yaml_ng::from_str(&content)
377 .map_err(|e| format!("failed to parse config {}: {}", path.display(), e))?;
378 let config: OagConfig = serde_json::from_value(yaml_value)
379 .map_err(|e| format!("failed to parse config {}: {}", path.display(), e))?;
380 Ok(Some(config))
381}
382
383pub fn default_config_content() -> &'static str {
385 r#"# oag configuration — https://github.com/urmzd/openapi-generator
386input: openapi.yaml
387
388naming:
389 strategy: use_operation_id # use_operation_id | use_route_based
390 aliases: {}
391 # createChatCompletion: chat # operationId → custom name
392 # listModels: models
393
394generators:
395 node-client:
396 output: src/generated/node
397 layout: modular # bundled | modular | split
398 # split_by: tag # operation | tag | route (only for split layout)
399 # base_url: https://api.example.com
400 # no_jsdoc: false
401 # source_dir: src # subdirectory for source files ("src", "lib", or "" for root)
402 scaffold:
403 # package_name: my-api-client
404 # repository: https://github.com/you/your-repo
405 # existing_repo: false # set to true to skip all scaffold files (package.json, tsconfig, etc.)
406 formatter: biome # biome | false
407 test_runner: vitest # vitest | false
408 bundler: tsdown # tsdown | false
409
410 # react-swr-client:
411 # output: src/generated/react
412 # layout: modular
413 # scaffold:
414 # formatter: biome
415 # test_runner: vitest
416 # bundler: tsdown
417
418 # fastapi-server:
419 # output: src/generated/server
420 # layout: modular
421 # scaffold:
422 # formatter: ruff # ruff | false
423 # test_runner: pytest # pytest | false
424"#
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 #[test]
432 fn test_default_config() {
433 let config = OagConfig::default();
434 assert_eq!(config.input, "openapi.yaml");
435 assert_eq!(config.naming.strategy, NamingStrategy::UseOperationId);
436 assert!(config.naming.aliases.is_empty());
437 assert!(config.generators.is_empty());
438 }
439
440 #[test]
441 fn test_parse_new_format() {
442 let yaml = r#"
443input: spec.yaml
444
445naming:
446 strategy: use_route_based
447 aliases:
448 createChatCompletion: chat
449
450generators:
451 node-client:
452 output: out/node
453 layout: modular
454 base_url: https://api.example.com
455 scaffold:
456 package_name: "@myorg/client"
457 formatter: biome
458 bundler: tsdown
459 react-swr-client:
460 output: out/react
461 layout: split
462 split_by: tag
463"#;
464 let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
465 let config: OagConfig = serde_json::from_value(value).unwrap();
466 assert_eq!(config.input, "spec.yaml");
467 assert_eq!(config.naming.strategy, NamingStrategy::UseRouteBased);
468 assert_eq!(config.generators.len(), 2);
469
470 let node = &config.generators[&GeneratorId::NodeClient];
471 assert_eq!(node.output, "out/node");
472 assert_eq!(node.layout, OutputLayout::Modular);
473 assert_eq!(node.base_url, Some("https://api.example.com".to_string()));
474 assert!(node.scaffold.is_some());
475 let scaffold = node.scaffold.as_ref().unwrap();
476 assert_eq!(scaffold["package_name"], "@myorg/client");
477 assert_eq!(scaffold["formatter"], "biome");
478 assert_eq!(scaffold["bundler"], "tsdown");
479
480 let react = &config.generators[&GeneratorId::ReactSwrClient];
481 assert_eq!(react.output, "out/react");
482 assert_eq!(react.layout, OutputLayout::Split);
483 assert_eq!(react.split_by, Some(SplitBy::Tag));
484 }
485
486 #[test]
487 fn test_parse_legacy_typescript() {
488 let yaml = r#"
489input: spec.yaml
490output: out
491target: typescript
492naming:
493 strategy: use_operation_id
494 aliases: {}
495output_options:
496 layout: single
497 biome: true
498 tsdown: true
499client:
500 base_url: https://api.example.com
501 no_jsdoc: true
502"#;
503 let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
504 let config: OagConfig = serde_json::from_value(value).unwrap();
505 assert_eq!(config.input, "spec.yaml");
506 assert_eq!(config.generators.len(), 1);
507 assert!(config.generators.contains_key(&GeneratorId::NodeClient));
508
509 let node_gen = &config.generators[&GeneratorId::NodeClient];
510 assert_eq!(node_gen.output, "out");
511 assert_eq!(
512 node_gen.base_url,
513 Some("https://api.example.com".to_string())
514 );
515 assert_eq!(node_gen.no_jsdoc, Some(true));
516 }
517
518 #[test]
519 fn test_parse_legacy_react() {
520 let yaml = r#"
521input: spec.yaml
522output: out
523target: react
524"#;
525 let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
526 let config: OagConfig = serde_json::from_value(value).unwrap();
527 assert_eq!(config.generators.len(), 1);
528 assert!(config.generators.contains_key(&GeneratorId::ReactSwrClient));
529 }
530
531 #[test]
532 fn test_parse_legacy_all_single() {
533 let yaml = r#"
534input: spec.yaml
535output: out
536target: all
537output_options:
538 layout: single
539"#;
540 let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
541 let config: OagConfig = serde_json::from_value(value).unwrap();
542 assert_eq!(config.generators.len(), 1);
544 assert!(config.generators.contains_key(&GeneratorId::ReactSwrClient));
545 }
546
547 #[test]
548 fn test_parse_legacy_all_split() {
549 let yaml = r#"
550input: spec.yaml
551output: out
552target: all
553output_options:
554 layout: split
555"#;
556 let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
557 let config: OagConfig = serde_json::from_value(value).unwrap();
558 assert_eq!(config.generators.len(), 2);
559 assert!(config.generators.contains_key(&GeneratorId::NodeClient));
560 assert!(config.generators.contains_key(&GeneratorId::ReactSwrClient));
561 assert_eq!(
562 config.generators[&GeneratorId::NodeClient].output,
563 "out/typescript"
564 );
565 assert_eq!(
566 config.generators[&GeneratorId::ReactSwrClient].output,
567 "out/react"
568 );
569 }
570
571 #[test]
572 fn test_tool_setting_resolve() {
573 assert_eq!(ToolSetting::resolve(None, "biome"), Some("biome"));
574 assert_eq!(
575 ToolSetting::resolve(Some(&ToolSetting::Named("ruff".into())), "biome"),
576 Some("ruff")
577 );
578 assert_eq!(
579 ToolSetting::resolve(Some(&ToolSetting::Disabled), "biome"),
580 None
581 );
582 }
583
584 #[test]
585 fn test_tool_setting_deserialize() {
586 let named: ToolSetting = serde_json::from_value(serde_json::json!("biome")).unwrap();
587 assert_eq!(named, ToolSetting::Named("biome".into()));
588
589 let disabled: ToolSetting = serde_json::from_value(serde_json::json!(false)).unwrap();
590 assert_eq!(disabled, ToolSetting::Disabled);
591
592 let err = serde_json::from_value::<ToolSetting>(serde_json::json!(true));
593 assert!(err.is_err());
594 }
595
596 #[test]
597 fn test_parse_minimal_config() {
598 let yaml = "input: api.yaml\n";
599 let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
600 let config: OagConfig = serde_json::from_value(value).unwrap();
601 assert_eq!(config.input, "api.yaml");
602 assert_eq!(config.generators.len(), 1);
604 }
605}