use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use indexmap::IndexMap;
use serde::de;
use serde::{Deserialize, Deserializer};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolSetting {
Named(String),
Disabled,
}
impl ToolSetting {
pub fn resolve<'a>(setting: Option<&'a Self>, default: &'a str) -> Option<&'a str> {
match setting {
None => Some(default),
Some(ToolSetting::Named(s)) => Some(s.as_str()),
Some(ToolSetting::Disabled) => None,
}
}
}
impl<'de> Deserialize<'de> for ToolSetting {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer).map_err(de::Error::custom)?;
match value {
serde_json::Value::String(s) => Ok(ToolSetting::Named(s)),
serde_json::Value::Bool(false) => Ok(ToolSetting::Disabled),
serde_json::Value::Bool(true) => {
Err(de::Error::custom(
"use `false` to disable or a string to name the tool; `true` is treated as default (omit the field)",
))
}
_ => Err(de::Error::custom(
"expected a tool name string or `false` to disable",
)),
}
}
}
fn deserialize_scaffold<'de, D>(deserializer: D) -> Result<Option<serde_json::Value>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<serde_json::Value>::deserialize(deserializer)?;
match value {
Some(serde_json::Value::Bool(false)) => Ok(None),
other => Ok(other),
}
}
#[derive(Debug, Clone)]
pub struct OagConfig {
pub input: String,
pub naming: NamingConfig,
pub generators: IndexMap<GeneratorId, GeneratorConfig>,
}
impl Default for OagConfig {
fn default() -> Self {
Self {
input: "openapi.yaml".to_string(),
naming: NamingConfig::default(),
generators: IndexMap::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize)]
#[serde(transparent)]
pub struct GeneratorId(pub String);
impl GeneratorId {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for GeneratorId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct GeneratorConfig {
pub output: String,
pub layout: OutputLayout,
pub split_by: Option<SplitBy>,
pub base_url: Option<String>,
pub no_jsdoc: Option<bool>,
pub source_dir: String,
#[serde(default, deserialize_with = "deserialize_scaffold")]
pub scaffold: Option<serde_json::Value>,
}
impl Default for GeneratorConfig {
fn default() -> Self {
Self {
output: "src/generated".to_string(),
layout: OutputLayout::Modular,
split_by: None,
base_url: None,
no_jsdoc: None,
source_dir: "".to_string(),
scaffold: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OutputLayout {
Bundled,
Modular,
Split,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SplitBy {
Operation,
Tag,
Route,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct NamingConfig {
pub strategy: NamingStrategy,
#[serde(default)]
pub aliases: IndexMap<String, String>,
}
impl Default for NamingConfig {
fn default() -> Self {
Self {
strategy: NamingStrategy::UseOperationId,
aliases: IndexMap::new(),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NamingStrategy {
#[default]
UseOperationId,
UseRouteBased,
}
#[derive(Deserialize)]
struct LegacyConfig {
#[serde(default = "default_input")]
input: String,
#[serde(default = "default_output")]
output: String,
#[serde(default)]
target: LegacyTargetKind,
#[serde(default)]
naming: NamingConfig,
#[serde(default)]
output_options: LegacyOutputOptions,
#[serde(default)]
client: LegacyClientConfig,
}
fn default_input() -> String {
"openapi.yaml".to_string()
}
fn default_output() -> String {
"src/generated".to_string()
}
#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
enum LegacyTargetKind {
Typescript,
React,
#[default]
All,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
struct LegacyOutputOptions {
layout: LegacyOutputLayout,
index: bool,
biome: bool,
tsdown: bool,
package_name: Option<String>,
repository: Option<String>,
}
impl Default for LegacyOutputOptions {
fn default() -> Self {
Self {
layout: LegacyOutputLayout::Single,
index: true,
biome: true,
tsdown: true,
package_name: None,
repository: None,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
enum LegacyOutputLayout {
#[default]
Single,
Split,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
struct LegacyClientConfig {
base_url: Option<String>,
no_jsdoc: bool,
}
#[derive(Deserialize)]
struct NewConfig {
#[serde(default = "default_input")]
input: String,
#[serde(default)]
naming: NamingConfig,
generators: IndexMap<GeneratorId, GeneratorConfig>,
}
impl<'de> Deserialize<'de> for OagConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer).map_err(de::Error::custom)?;
if value.get("generators").is_some() {
let new_cfg: NewConfig = serde_json::from_value(value).map_err(de::Error::custom)?;
Ok(OagConfig {
input: new_cfg.input,
naming: new_cfg.naming,
generators: new_cfg.generators,
})
} else {
let legacy: LegacyConfig = serde_json::from_value(value).map_err(de::Error::custom)?;
Ok(convert_legacy(legacy))
}
}
}
fn convert_legacy(legacy: LegacyConfig) -> OagConfig {
let scaffold = Some(serde_json::json!({
"package_name": legacy.output_options.package_name,
"repository": legacy.output_options.repository,
"index": legacy.output_options.index,
"formatter": if legacy.output_options.biome { serde_json::Value::String("biome".into()) } else { serde_json::Value::Bool(false) },
"bundler": if legacy.output_options.tsdown { serde_json::Value::String("tsdown".into()) } else { serde_json::Value::Bool(false) },
"test_runner": serde_json::Value::String("vitest".into()),
}));
let base_gen_config = |output: String| GeneratorConfig {
output,
layout: OutputLayout::Modular,
split_by: None,
base_url: legacy.client.base_url.clone(),
no_jsdoc: Some(legacy.client.no_jsdoc),
source_dir: "src".to_string(),
scaffold: scaffold.clone(),
};
let mut generators = IndexMap::new();
match (&legacy.target, &legacy.output_options.layout) {
(LegacyTargetKind::Typescript, _) => {
generators.insert(
GeneratorId("node-client".into()),
base_gen_config(legacy.output.clone()),
);
}
(LegacyTargetKind::React, _) => {
generators.insert(
GeneratorId("react-swr-client".into()),
base_gen_config(legacy.output.clone()),
);
}
(LegacyTargetKind::All, LegacyOutputLayout::Single) => {
generators.insert(
GeneratorId("react-swr-client".into()),
base_gen_config(legacy.output.clone()),
);
}
(LegacyTargetKind::All, LegacyOutputLayout::Split) => {
let ts_output = format!("{}/typescript", legacy.output);
let react_output = format!("{}/react", legacy.output);
generators.insert(
GeneratorId("node-client".into()),
base_gen_config(ts_output),
);
generators.insert(
GeneratorId("react-swr-client".into()),
base_gen_config(react_output),
);
}
}
OagConfig {
input: legacy.input,
naming: legacy.naming,
generators,
}
}
pub const CONFIG_FILE_NAME: &str = "oag.yaml";
pub const LEGACY_CONFIG_FILE: &str = ".urmzd.oag.yaml";
pub const CONFIG_CANDIDATES: &[&str] = &["oag.yaml", "oag.yml", LEGACY_CONFIG_FILE];
pub fn find_config(dir: &Path) -> Option<(PathBuf, bool)> {
for &candidate in CONFIG_CANDIDATES {
let path = dir.join(candidate);
if path.exists() {
let is_legacy = candidate == LEGACY_CONFIG_FILE;
return Some((path, is_legacy));
}
}
None
}
pub fn load_config(path: &Path) -> Result<Option<OagConfig>, String> {
if !path.exists() {
return Ok(None);
}
let content = fs::read_to_string(path)
.map_err(|e| format!("failed to read config {}: {}", path.display(), e))?;
let yaml_value: serde_json::Value = serde_yaml_ng::from_str(&content)
.map_err(|e| format!("failed to parse config {}: {}", path.display(), e))?;
let config: OagConfig = serde_json::from_value(yaml_value)
.map_err(|e| format!("failed to parse config {}: {}", path.display(), e))?;
Ok(Some(config))
}
pub fn default_config_content() -> &'static str {
include_str!("../default-config.yaml")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = OagConfig::default();
assert_eq!(config.input, "openapi.yaml");
assert_eq!(config.naming.strategy, NamingStrategy::UseOperationId);
assert!(config.naming.aliases.is_empty());
assert!(config.generators.is_empty());
}
#[test]
fn test_parse_new_format() {
let yaml = r#"
input: spec.yaml
naming:
strategy: use_route_based
aliases:
createChatCompletion: chat
generators:
node-client:
output: out/node
layout: modular
base_url: https://api.example.com
scaffold:
package_name: "@myorg/client"
formatter: biome
bundler: tsdown
react-swr-client:
output: out/react
layout: split
split_by: tag
"#;
let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
let config: OagConfig = serde_json::from_value(value).unwrap();
assert_eq!(config.input, "spec.yaml");
assert_eq!(config.naming.strategy, NamingStrategy::UseRouteBased);
assert_eq!(config.generators.len(), 2);
let node = &config.generators[&GeneratorId("node-client".into())];
assert_eq!(node.output, "out/node");
assert_eq!(node.layout, OutputLayout::Modular);
assert_eq!(node.base_url, Some("https://api.example.com".to_string()));
assert!(node.scaffold.is_some());
let scaffold = node.scaffold.as_ref().unwrap();
assert_eq!(scaffold["package_name"], "@myorg/client");
assert_eq!(scaffold["formatter"], "biome");
assert_eq!(scaffold["bundler"], "tsdown");
let react = &config.generators[&GeneratorId("react-swr-client".into())];
assert_eq!(react.output, "out/react");
assert_eq!(react.layout, OutputLayout::Split);
assert_eq!(react.split_by, Some(SplitBy::Tag));
}
#[test]
fn test_parse_legacy_typescript() {
let yaml = r#"
input: spec.yaml
output: out
target: typescript
naming:
strategy: use_operation_id
aliases: {}
output_options:
layout: single
biome: true
tsdown: true
client:
base_url: https://api.example.com
no_jsdoc: true
"#;
let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
let config: OagConfig = serde_json::from_value(value).unwrap();
assert_eq!(config.input, "spec.yaml");
assert_eq!(config.generators.len(), 1);
assert!(
config
.generators
.contains_key(&GeneratorId("node-client".into()))
);
let node_gen = &config.generators[&GeneratorId("node-client".into())];
assert_eq!(node_gen.output, "out");
assert_eq!(
node_gen.base_url,
Some("https://api.example.com".to_string())
);
assert_eq!(node_gen.no_jsdoc, Some(true));
}
#[test]
fn test_parse_legacy_react() {
let yaml = r#"
input: spec.yaml
output: out
target: react
"#;
let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
let config: OagConfig = serde_json::from_value(value).unwrap();
assert_eq!(config.generators.len(), 1);
assert!(
config
.generators
.contains_key(&GeneratorId("react-swr-client".into()))
);
}
#[test]
fn test_parse_legacy_all_single() {
let yaml = r#"
input: spec.yaml
output: out
target: all
output_options:
layout: single
"#;
let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
let config: OagConfig = serde_json::from_value(value).unwrap();
assert_eq!(config.generators.len(), 1);
assert!(
config
.generators
.contains_key(&GeneratorId("react-swr-client".into()))
);
}
#[test]
fn test_parse_legacy_all_split() {
let yaml = r#"
input: spec.yaml
output: out
target: all
output_options:
layout: split
"#;
let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
let config: OagConfig = serde_json::from_value(value).unwrap();
assert_eq!(config.generators.len(), 2);
assert!(
config
.generators
.contains_key(&GeneratorId("node-client".into()))
);
assert!(
config
.generators
.contains_key(&GeneratorId("react-swr-client".into()))
);
assert_eq!(
config.generators[&GeneratorId("node-client".into())].output,
"out/typescript"
);
assert_eq!(
config.generators[&GeneratorId("react-swr-client".into())].output,
"out/react"
);
}
#[test]
fn test_tool_setting_resolve() {
assert_eq!(ToolSetting::resolve(None, "biome"), Some("biome"));
assert_eq!(
ToolSetting::resolve(Some(&ToolSetting::Named("ruff".into())), "biome"),
Some("ruff")
);
assert_eq!(
ToolSetting::resolve(Some(&ToolSetting::Disabled), "biome"),
None
);
}
#[test]
fn test_tool_setting_deserialize() {
let named: ToolSetting = serde_json::from_value(serde_json::json!("biome")).unwrap();
assert_eq!(named, ToolSetting::Named("biome".into()));
let disabled: ToolSetting = serde_json::from_value(serde_json::json!(false)).unwrap();
assert_eq!(disabled, ToolSetting::Disabled);
let err = serde_json::from_value::<ToolSetting>(serde_json::json!(true));
assert!(err.is_err());
}
#[test]
fn test_parse_minimal_config() {
let yaml = "input: api.yaml\n";
let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
let config: OagConfig = serde_json::from_value(value).unwrap();
assert_eq!(config.input, "api.yaml");
assert_eq!(config.generators.len(), 1);
}
#[test]
fn test_scaffold_false_disables_scaffolding() {
let yaml = r#"
generators:
node-client:
output: out/node
scaffold: false
fastapi-server:
output: out/server
scaffold: false
"#;
let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
let config: OagConfig = serde_json::from_value(value).unwrap();
let node = &config.generators[&GeneratorId("node-client".into())];
assert!(
node.scaffold.is_none(),
"scaffold: false should become None"
);
let fastapi = &config.generators[&GeneratorId("fastapi-server".into())];
assert!(
fastapi.scaffold.is_none(),
"scaffold: false should become None"
);
}
#[test]
fn test_scaffold_omitted_is_none() {
let yaml = r#"
generators:
node-client:
output: out/node
"#;
let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
let config: OagConfig = serde_json::from_value(value).unwrap();
let node = &config.generators[&GeneratorId("node-client".into())];
assert!(node.scaffold.is_none());
}
}