1use std::fmt;
2use std::fs;
3use std::path::{Path, PathBuf};
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
52fn deserialize_scaffold<'de, D>(deserializer: D) -> Result<Option<serde_json::Value>, D::Error>
54where
55 D: Deserializer<'de>,
56{
57 let value = Option::<serde_json::Value>::deserialize(deserializer)?;
58 match value {
59 Some(serde_json::Value::Bool(false)) => Ok(None),
60 other => Ok(other),
61 }
62}
63
64#[derive(Debug, Clone)]
77pub struct OagConfig {
78 pub input: String,
81 pub naming: NamingConfig,
83 pub generators: IndexMap<GeneratorId, GeneratorConfig>,
86}
87
88impl Default for OagConfig {
89 fn default() -> Self {
90 Self {
91 input: "openapi.yaml".to_string(),
92 naming: NamingConfig::default(),
93 generators: IndexMap::new(),
94 }
95 }
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize)]
108#[serde(transparent)]
109pub struct GeneratorId(pub String);
110
111impl GeneratorId {
112 pub fn as_str(&self) -> &str {
113 &self.0
114 }
115}
116
117impl fmt::Display for GeneratorId {
118 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119 write!(f, "{}", self.0)
120 }
121}
122
123#[derive(Debug, Clone, Deserialize)]
140#[serde(default)]
141pub struct GeneratorConfig {
142 pub output: String,
145 pub layout: OutputLayout,
150 pub split_by: Option<SplitBy>,
155 pub base_url: Option<String>,
159 pub no_jsdoc: Option<bool>,
162 pub source_dir: String,
167 #[serde(default, deserialize_with = "deserialize_scaffold")]
192 pub scaffold: Option<serde_json::Value>,
193}
194
195impl Default for GeneratorConfig {
196 fn default() -> Self {
197 Self {
198 output: "src/generated".to_string(),
199 layout: OutputLayout::Modular,
200 split_by: None,
201 base_url: None,
202 no_jsdoc: None,
203 source_dir: "".to_string(),
204 scaffold: None,
205 }
206 }
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
217#[serde(rename_all = "snake_case")]
218pub enum OutputLayout {
219 Bundled,
223 Modular,
227 Split,
231}
232
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
241#[serde(rename_all = "snake_case")]
242pub enum SplitBy {
243 Operation,
246 Tag,
249 Route,
252}
253
254#[derive(Debug, Clone, Deserialize)]
259#[serde(default)]
260pub struct NamingConfig {
261 pub strategy: NamingStrategy,
263 #[serde(default)]
267 pub aliases: IndexMap<String, String>,
268}
269
270impl Default for NamingConfig {
271 fn default() -> Self {
272 Self {
273 strategy: NamingStrategy::UseOperationId,
274 aliases: IndexMap::new(),
275 }
276 }
277}
278
279#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
286#[serde(rename_all = "snake_case")]
287pub enum NamingStrategy {
288 #[default]
292 UseOperationId,
293 UseRouteBased,
297}
298
299#[derive(Deserialize)]
306struct LegacyConfig {
307 #[serde(default = "default_input")]
308 input: String,
309 #[serde(default = "default_output")]
310 output: String,
311 #[serde(default)]
312 target: LegacyTargetKind,
313 #[serde(default)]
314 naming: NamingConfig,
315 #[serde(default)]
316 output_options: LegacyOutputOptions,
317 #[serde(default)]
318 client: LegacyClientConfig,
319}
320
321fn default_input() -> String {
322 "openapi.yaml".to_string()
323}
324fn default_output() -> String {
325 "src/generated".to_string()
326}
327
328#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
329#[serde(rename_all = "snake_case")]
330enum LegacyTargetKind {
331 Typescript,
332 React,
333 #[default]
334 All,
335}
336
337#[derive(Debug, Clone, Deserialize)]
338#[serde(default)]
339struct LegacyOutputOptions {
340 layout: LegacyOutputLayout,
341 index: bool,
342 biome: bool,
343 tsdown: bool,
344 package_name: Option<String>,
345 repository: Option<String>,
346}
347
348impl Default for LegacyOutputOptions {
349 fn default() -> Self {
350 Self {
351 layout: LegacyOutputLayout::Single,
352 index: true,
353 biome: true,
354 tsdown: true,
355 package_name: None,
356 repository: None,
357 }
358 }
359}
360
361#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
362#[serde(rename_all = "snake_case")]
363enum LegacyOutputLayout {
364 #[default]
365 Single,
366 Split,
367}
368
369#[derive(Debug, Clone, Default, Deserialize)]
370#[serde(default)]
371struct LegacyClientConfig {
372 base_url: Option<String>,
373 no_jsdoc: bool,
374}
375
376#[derive(Deserialize)]
378struct NewConfig {
379 #[serde(default = "default_input")]
380 input: String,
381 #[serde(default)]
382 naming: NamingConfig,
383 generators: IndexMap<GeneratorId, GeneratorConfig>,
384}
385
386impl<'de> Deserialize<'de> for OagConfig {
387 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
388 where
389 D: Deserializer<'de>,
390 {
391 let value = serde_json::Value::deserialize(deserializer).map_err(de::Error::custom)?;
393
394 if value.get("generators").is_some() {
396 let new_cfg: NewConfig = serde_json::from_value(value).map_err(de::Error::custom)?;
397 Ok(OagConfig {
398 input: new_cfg.input,
399 naming: new_cfg.naming,
400 generators: new_cfg.generators,
401 })
402 } else {
403 let legacy: LegacyConfig = serde_json::from_value(value).map_err(de::Error::custom)?;
405 Ok(convert_legacy(legacy))
406 }
407 }
408}
409
410fn convert_legacy(legacy: LegacyConfig) -> OagConfig {
411 let scaffold = Some(serde_json::json!({
412 "package_name": legacy.output_options.package_name,
413 "repository": legacy.output_options.repository,
414 "index": legacy.output_options.index,
415 "formatter": if legacy.output_options.biome { serde_json::Value::String("biome".into()) } else { serde_json::Value::Bool(false) },
416 "bundler": if legacy.output_options.tsdown { serde_json::Value::String("tsdown".into()) } else { serde_json::Value::Bool(false) },
417 "test_runner": serde_json::Value::String("vitest".into()),
418 }));
419
420 let base_gen_config = |output: String| GeneratorConfig {
421 output,
422 layout: OutputLayout::Modular,
423 split_by: None,
424 base_url: legacy.client.base_url.clone(),
425 no_jsdoc: Some(legacy.client.no_jsdoc),
426 source_dir: "src".to_string(),
427 scaffold: scaffold.clone(),
428 };
429
430 let mut generators = IndexMap::new();
431
432 match (&legacy.target, &legacy.output_options.layout) {
433 (LegacyTargetKind::Typescript, _) => {
434 generators.insert(
435 GeneratorId("node-client".into()),
436 base_gen_config(legacy.output.clone()),
437 );
438 }
439 (LegacyTargetKind::React, _) => {
440 generators.insert(
441 GeneratorId("react-swr-client".into()),
442 base_gen_config(legacy.output.clone()),
443 );
444 }
445 (LegacyTargetKind::All, LegacyOutputLayout::Single) => {
446 generators.insert(
450 GeneratorId("react-swr-client".into()),
451 base_gen_config(legacy.output.clone()),
452 );
453 }
454 (LegacyTargetKind::All, LegacyOutputLayout::Split) => {
455 let ts_output = format!("{}/typescript", legacy.output);
456 let react_output = format!("{}/react", legacy.output);
457 generators.insert(
458 GeneratorId("node-client".into()),
459 base_gen_config(ts_output),
460 );
461 generators.insert(
462 GeneratorId("react-swr-client".into()),
463 base_gen_config(react_output),
464 );
465 }
466 }
467
468 OagConfig {
469 input: legacy.input,
470 naming: legacy.naming,
471 generators,
472 }
473}
474
475pub const CONFIG_FILE_NAME: &str = "oag.yaml";
477
478pub const LEGACY_CONFIG_FILE: &str = ".urmzd.oag.yaml";
480
481pub const CONFIG_CANDIDATES: &[&str] = &["oag.yaml", "oag.yml", LEGACY_CONFIG_FILE];
483
484pub fn find_config(dir: &Path) -> Option<(PathBuf, bool)> {
487 for &candidate in CONFIG_CANDIDATES {
488 let path = dir.join(candidate);
489 if path.exists() {
490 let is_legacy = candidate == LEGACY_CONFIG_FILE;
491 return Some((path, is_legacy));
492 }
493 }
494 None
495}
496
497pub fn load_config(path: &Path) -> Result<Option<OagConfig>, String> {
499 if !path.exists() {
500 return Ok(None);
501 }
502 let content = fs::read_to_string(path)
503 .map_err(|e| format!("failed to read config {}: {}", path.display(), e))?;
504
505 let yaml_value: serde_json::Value = serde_yaml_ng::from_str(&content)
506 .map_err(|e| format!("failed to parse config {}: {}", path.display(), e))?;
507
508 let config: OagConfig = serde_json::from_value(yaml_value)
509 .map_err(|e| format!("failed to parse config {}: {}", path.display(), e))?;
510 Ok(Some(config))
511}
512
513pub fn default_config_content() -> &'static str {
515 include_str!("../default-config.yaml")
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 #[test]
523 fn test_default_config() {
524 let config = OagConfig::default();
525 assert_eq!(config.input, "openapi.yaml");
526 assert_eq!(config.naming.strategy, NamingStrategy::UseOperationId);
527 assert!(config.naming.aliases.is_empty());
528 assert!(config.generators.is_empty());
529 }
530
531 #[test]
532 fn test_parse_new_format() {
533 let yaml = r#"
534input: spec.yaml
535
536naming:
537 strategy: use_route_based
538 aliases:
539 createChatCompletion: chat
540
541generators:
542 node-client:
543 output: out/node
544 layout: modular
545 base_url: https://api.example.com
546 scaffold:
547 package_name: "@myorg/client"
548 formatter: biome
549 bundler: tsdown
550 react-swr-client:
551 output: out/react
552 layout: split
553 split_by: tag
554"#;
555 let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
556 let config: OagConfig = serde_json::from_value(value).unwrap();
557 assert_eq!(config.input, "spec.yaml");
558 assert_eq!(config.naming.strategy, NamingStrategy::UseRouteBased);
559 assert_eq!(config.generators.len(), 2);
560
561 let node = &config.generators[&GeneratorId("node-client".into())];
562 assert_eq!(node.output, "out/node");
563 assert_eq!(node.layout, OutputLayout::Modular);
564 assert_eq!(node.base_url, Some("https://api.example.com".to_string()));
565 assert!(node.scaffold.is_some());
566 let scaffold = node.scaffold.as_ref().unwrap();
567 assert_eq!(scaffold["package_name"], "@myorg/client");
568 assert_eq!(scaffold["formatter"], "biome");
569 assert_eq!(scaffold["bundler"], "tsdown");
570
571 let react = &config.generators[&GeneratorId("react-swr-client".into())];
572 assert_eq!(react.output, "out/react");
573 assert_eq!(react.layout, OutputLayout::Split);
574 assert_eq!(react.split_by, Some(SplitBy::Tag));
575 }
576
577 #[test]
578 fn test_parse_legacy_typescript() {
579 let yaml = r#"
580input: spec.yaml
581output: out
582target: typescript
583naming:
584 strategy: use_operation_id
585 aliases: {}
586output_options:
587 layout: single
588 biome: true
589 tsdown: true
590client:
591 base_url: https://api.example.com
592 no_jsdoc: true
593"#;
594 let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
595 let config: OagConfig = serde_json::from_value(value).unwrap();
596 assert_eq!(config.input, "spec.yaml");
597 assert_eq!(config.generators.len(), 1);
598 assert!(
599 config
600 .generators
601 .contains_key(&GeneratorId("node-client".into()))
602 );
603
604 let node_gen = &config.generators[&GeneratorId("node-client".into())];
605 assert_eq!(node_gen.output, "out");
606 assert_eq!(
607 node_gen.base_url,
608 Some("https://api.example.com".to_string())
609 );
610 assert_eq!(node_gen.no_jsdoc, Some(true));
611 }
612
613 #[test]
614 fn test_parse_legacy_react() {
615 let yaml = r#"
616input: spec.yaml
617output: out
618target: react
619"#;
620 let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
621 let config: OagConfig = serde_json::from_value(value).unwrap();
622 assert_eq!(config.generators.len(), 1);
623 assert!(
624 config
625 .generators
626 .contains_key(&GeneratorId("react-swr-client".into()))
627 );
628 }
629
630 #[test]
631 fn test_parse_legacy_all_single() {
632 let yaml = r#"
633input: spec.yaml
634output: out
635target: all
636output_options:
637 layout: single
638"#;
639 let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
640 let config: OagConfig = serde_json::from_value(value).unwrap();
641 assert_eq!(config.generators.len(), 1);
643 assert!(
644 config
645 .generators
646 .contains_key(&GeneratorId("react-swr-client".into()))
647 );
648 }
649
650 #[test]
651 fn test_parse_legacy_all_split() {
652 let yaml = r#"
653input: spec.yaml
654output: out
655target: all
656output_options:
657 layout: split
658"#;
659 let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
660 let config: OagConfig = serde_json::from_value(value).unwrap();
661 assert_eq!(config.generators.len(), 2);
662 assert!(
663 config
664 .generators
665 .contains_key(&GeneratorId("node-client".into()))
666 );
667 assert!(
668 config
669 .generators
670 .contains_key(&GeneratorId("react-swr-client".into()))
671 );
672 assert_eq!(
673 config.generators[&GeneratorId("node-client".into())].output,
674 "out/typescript"
675 );
676 assert_eq!(
677 config.generators[&GeneratorId("react-swr-client".into())].output,
678 "out/react"
679 );
680 }
681
682 #[test]
683 fn test_tool_setting_resolve() {
684 assert_eq!(ToolSetting::resolve(None, "biome"), Some("biome"));
685 assert_eq!(
686 ToolSetting::resolve(Some(&ToolSetting::Named("ruff".into())), "biome"),
687 Some("ruff")
688 );
689 assert_eq!(
690 ToolSetting::resolve(Some(&ToolSetting::Disabled), "biome"),
691 None
692 );
693 }
694
695 #[test]
696 fn test_tool_setting_deserialize() {
697 let named: ToolSetting = serde_json::from_value(serde_json::json!("biome")).unwrap();
698 assert_eq!(named, ToolSetting::Named("biome".into()));
699
700 let disabled: ToolSetting = serde_json::from_value(serde_json::json!(false)).unwrap();
701 assert_eq!(disabled, ToolSetting::Disabled);
702
703 let err = serde_json::from_value::<ToolSetting>(serde_json::json!(true));
704 assert!(err.is_err());
705 }
706
707 #[test]
708 fn test_parse_minimal_config() {
709 let yaml = "input: api.yaml\n";
710 let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
711 let config: OagConfig = serde_json::from_value(value).unwrap();
712 assert_eq!(config.input, "api.yaml");
713 assert_eq!(config.generators.len(), 1);
715 }
716
717 #[test]
718 fn test_scaffold_false_disables_scaffolding() {
719 let yaml = r#"
720generators:
721 node-client:
722 output: out/node
723 scaffold: false
724 fastapi-server:
725 output: out/server
726 scaffold: false
727"#;
728 let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
729 let config: OagConfig = serde_json::from_value(value).unwrap();
730
731 let node = &config.generators[&GeneratorId("node-client".into())];
732 assert!(
733 node.scaffold.is_none(),
734 "scaffold: false should become None"
735 );
736
737 let fastapi = &config.generators[&GeneratorId("fastapi-server".into())];
738 assert!(
739 fastapi.scaffold.is_none(),
740 "scaffold: false should become None"
741 );
742 }
743
744 #[test]
745 fn test_scaffold_omitted_is_none() {
746 let yaml = r#"
747generators:
748 node-client:
749 output: out/node
750"#;
751 let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
752 let config: OagConfig = serde_json::from_value(value).unwrap();
753
754 let node = &config.generators[&GeneratorId("node-client".into())];
755 assert!(node.scaffold.is_none());
756 }
757}