Skip to main content

oag_core/
config.rs

1use std::fmt;
2use std::fs;
3use std::path::Path;
4
5use indexmap::IndexMap;
6use serde::de;
7use serde::{Deserialize, Deserializer};
8
9/// A tool setting that can be a named tool or explicitly disabled.
10///
11/// In YAML: `"biome"` → `Named("biome")`, `false` → `Disabled`.
12/// `true` or absent → treated as "use default" (represented as `None` at the Option level).
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum ToolSetting {
15    Named(String),
16    Disabled,
17}
18
19impl ToolSetting {
20    /// Resolve with a default: `None` → `Some(default)`, `Named(s)` → `Some(s)`, `Disabled` → `None`.
21    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                // true means "use default" — caller should treat as absent
41                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/// Top-level project configuration loaded from `.urmzd.oag.yaml`.
53#[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/// A generator plugin identifier.
71#[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/// Configuration for a single generator.
113#[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    /// Opaque scaffold config — each generator defines and parses its own struct.
122    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/// How generated files are laid out on disk.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
140#[serde(rename_all = "snake_case")]
141pub enum OutputLayout {
142    /// All files concatenated into one output file + scaffold.
143    Bundled,
144    /// Current behavior: types.ts, client.ts, sse.ts, index.ts.
145    Modular,
146    /// Per-group files with shared base.
147    Split,
148}
149
150/// How to split operations into groups.
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
152#[serde(rename_all = "snake_case")]
153pub enum SplitBy {
154    /// Group by operation (one file per operation).
155    Operation,
156    /// Group by tag (reuse IrModule).
157    Tag,
158    /// Group by path prefix.
159    Route,
160}
161
162/// Naming strategy and aliases.
163#[derive(Debug, Clone, Deserialize)]
164#[serde(default)]
165pub struct NamingConfig {
166    pub strategy: NamingStrategy,
167    /// Map from resolved operation name (operationId or route-derived) to custom alias.
168    #[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/// How operation names are derived.
182#[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// --- Backward-compatible deserialization ---
191// Old format had: input, output, target, naming, output_options, client
192// New format has: input, naming, generators (map of GeneratorId -> GeneratorConfig)
193// We support both.
194
195/// Internal legacy config format for backward compat parsing.
196#[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/// Internal new-format config for forward parsing.
268#[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        // We deserialize into a generic map first to detect the format.
283        let value = serde_json::Value::deserialize(deserializer).map_err(de::Error::custom)?;
284
285        // Check if the config has a "generators" key — that's the new format.
286        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            // Legacy format
295            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            // In single layout with target=all, the old behavior was to put
337            // everything together using the React generator (which includes TS files).
338            // Map this to a single react-swr-client generator.
339            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
359/// Default config file name.
360pub const CONFIG_FILE_NAME: &str = ".urmzd.oag.yaml";
361
362/// Load config from a YAML file. Returns `None` if the file doesn't exist.
363pub 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    // Parse YAML to serde_json::Value first, then use our custom Deserialize impl
371    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
378/// Generate the default config file content (new format).
379pub 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        // Single layout with "all" maps to react-swr-client (which includes TS)
537        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        // Legacy format with defaults: target=all, layout=single -> react-swr-client
597        assert_eq!(config.generators.len(), 1);
598    }
599}