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    /// Subdirectory for generated source files. Default `"src"`.
122    /// Empty string `""` places files at the output root.
123    pub source_dir: String,
124    /// Opaque scaffold config — each generator defines and parses its own struct.
125    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/// How generated files are laid out on disk.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
144#[serde(rename_all = "snake_case")]
145pub enum OutputLayout {
146    /// All files concatenated into one output file + scaffold.
147    Bundled,
148    /// Current behavior: types.ts, client.ts, sse.ts, index.ts.
149    Modular,
150    /// Per-group files with shared base.
151    Split,
152}
153
154/// How to split operations into groups.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
156#[serde(rename_all = "snake_case")]
157pub enum SplitBy {
158    /// Group by operation (one file per operation).
159    Operation,
160    /// Group by tag (reuse IrModule).
161    Tag,
162    /// Group by path prefix.
163    Route,
164}
165
166/// Naming strategy and aliases.
167#[derive(Debug, Clone, Deserialize)]
168#[serde(default)]
169pub struct NamingConfig {
170    pub strategy: NamingStrategy,
171    /// Map from resolved operation name (operationId or route-derived) to custom alias.
172    #[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/// How operation names are derived.
186#[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// --- Backward-compatible deserialization ---
195// Old format had: input, output, target, naming, output_options, client
196// New format has: input, naming, generators (map of GeneratorId -> GeneratorConfig)
197// We support both.
198
199/// Internal legacy config format for backward compat parsing.
200#[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/// Internal new-format config for forward parsing.
272#[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        // We deserialize into a generic map first to detect the format.
287        let value = serde_json::Value::deserialize(deserializer).map_err(de::Error::custom)?;
288
289        // Check if the config has a "generators" key — that's the new format.
290        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            // Legacy format
299            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            // In single layout with target=all, the old behavior was to put
342            // everything together using the React generator (which includes TS files).
343            // Map this to a single react-swr-client generator.
344            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
364/// Default config file name.
365pub const CONFIG_FILE_NAME: &str = ".urmzd.oag.yaml";
366
367/// Load config from a YAML file. Returns `None` if the file doesn't exist.
368pub 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    // Parse YAML to serde_json::Value first, then use our custom Deserialize impl
376    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
383/// Generate the default config file content (new format).
384pub fn default_config_content() -> &'static str {
385    include_str!("../default-config.yaml")
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn test_default_config() {
394        let config = OagConfig::default();
395        assert_eq!(config.input, "openapi.yaml");
396        assert_eq!(config.naming.strategy, NamingStrategy::UseOperationId);
397        assert!(config.naming.aliases.is_empty());
398        assert!(config.generators.is_empty());
399    }
400
401    #[test]
402    fn test_parse_new_format() {
403        let yaml = r#"
404input: spec.yaml
405
406naming:
407  strategy: use_route_based
408  aliases:
409    createChatCompletion: chat
410
411generators:
412  node-client:
413    output: out/node
414    layout: modular
415    base_url: https://api.example.com
416    scaffold:
417      package_name: "@myorg/client"
418      formatter: biome
419      bundler: tsdown
420  react-swr-client:
421    output: out/react
422    layout: split
423    split_by: tag
424"#;
425        let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
426        let config: OagConfig = serde_json::from_value(value).unwrap();
427        assert_eq!(config.input, "spec.yaml");
428        assert_eq!(config.naming.strategy, NamingStrategy::UseRouteBased);
429        assert_eq!(config.generators.len(), 2);
430
431        let node = &config.generators[&GeneratorId::NodeClient];
432        assert_eq!(node.output, "out/node");
433        assert_eq!(node.layout, OutputLayout::Modular);
434        assert_eq!(node.base_url, Some("https://api.example.com".to_string()));
435        assert!(node.scaffold.is_some());
436        let scaffold = node.scaffold.as_ref().unwrap();
437        assert_eq!(scaffold["package_name"], "@myorg/client");
438        assert_eq!(scaffold["formatter"], "biome");
439        assert_eq!(scaffold["bundler"], "tsdown");
440
441        let react = &config.generators[&GeneratorId::ReactSwrClient];
442        assert_eq!(react.output, "out/react");
443        assert_eq!(react.layout, OutputLayout::Split);
444        assert_eq!(react.split_by, Some(SplitBy::Tag));
445    }
446
447    #[test]
448    fn test_parse_legacy_typescript() {
449        let yaml = r#"
450input: spec.yaml
451output: out
452target: typescript
453naming:
454  strategy: use_operation_id
455  aliases: {}
456output_options:
457  layout: single
458  biome: true
459  tsdown: true
460client:
461  base_url: https://api.example.com
462  no_jsdoc: true
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.generators.len(), 1);
468        assert!(config.generators.contains_key(&GeneratorId::NodeClient));
469
470        let node_gen = &config.generators[&GeneratorId::NodeClient];
471        assert_eq!(node_gen.output, "out");
472        assert_eq!(
473            node_gen.base_url,
474            Some("https://api.example.com".to_string())
475        );
476        assert_eq!(node_gen.no_jsdoc, Some(true));
477    }
478
479    #[test]
480    fn test_parse_legacy_react() {
481        let yaml = r#"
482input: spec.yaml
483output: out
484target: react
485"#;
486        let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
487        let config: OagConfig = serde_json::from_value(value).unwrap();
488        assert_eq!(config.generators.len(), 1);
489        assert!(config.generators.contains_key(&GeneratorId::ReactSwrClient));
490    }
491
492    #[test]
493    fn test_parse_legacy_all_single() {
494        let yaml = r#"
495input: spec.yaml
496output: out
497target: all
498output_options:
499  layout: single
500"#;
501        let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
502        let config: OagConfig = serde_json::from_value(value).unwrap();
503        // Single layout with "all" maps to react-swr-client (which includes TS)
504        assert_eq!(config.generators.len(), 1);
505        assert!(config.generators.contains_key(&GeneratorId::ReactSwrClient));
506    }
507
508    #[test]
509    fn test_parse_legacy_all_split() {
510        let yaml = r#"
511input: spec.yaml
512output: out
513target: all
514output_options:
515  layout: split
516"#;
517        let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
518        let config: OagConfig = serde_json::from_value(value).unwrap();
519        assert_eq!(config.generators.len(), 2);
520        assert!(config.generators.contains_key(&GeneratorId::NodeClient));
521        assert!(config.generators.contains_key(&GeneratorId::ReactSwrClient));
522        assert_eq!(
523            config.generators[&GeneratorId::NodeClient].output,
524            "out/typescript"
525        );
526        assert_eq!(
527            config.generators[&GeneratorId::ReactSwrClient].output,
528            "out/react"
529        );
530    }
531
532    #[test]
533    fn test_tool_setting_resolve() {
534        assert_eq!(ToolSetting::resolve(None, "biome"), Some("biome"));
535        assert_eq!(
536            ToolSetting::resolve(Some(&ToolSetting::Named("ruff".into())), "biome"),
537            Some("ruff")
538        );
539        assert_eq!(
540            ToolSetting::resolve(Some(&ToolSetting::Disabled), "biome"),
541            None
542        );
543    }
544
545    #[test]
546    fn test_tool_setting_deserialize() {
547        let named: ToolSetting = serde_json::from_value(serde_json::json!("biome")).unwrap();
548        assert_eq!(named, ToolSetting::Named("biome".into()));
549
550        let disabled: ToolSetting = serde_json::from_value(serde_json::json!(false)).unwrap();
551        assert_eq!(disabled, ToolSetting::Disabled);
552
553        let err = serde_json::from_value::<ToolSetting>(serde_json::json!(true));
554        assert!(err.is_err());
555    }
556
557    #[test]
558    fn test_parse_minimal_config() {
559        let yaml = "input: api.yaml\n";
560        let value: serde_json::Value = serde_yaml_ng::from_str(yaml).unwrap();
561        let config: OagConfig = serde_json::from_value(value).unwrap();
562        assert_eq!(config.input, "api.yaml");
563        // Legacy format with defaults: target=all, layout=single -> react-swr-client
564        assert_eq!(config.generators.len(), 1);
565    }
566}