Skip to main content

oag_core/
config.rs

1use std::fmt;
2use std::fs;
3use std::path::{Path, PathBuf};
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/// Deserialize the `scaffold` field: `false` → `None`, object → `Some(object)`, absent → `None`.
53fn 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/// Top-level project configuration loaded from `oag.yaml`.
65///
66/// This is the entry point for all `oag` settings. The config file controls which
67/// OpenAPI spec to parse, how operation names are derived, and which code generators
68/// to run (each with its own output directory and options).
69///
70/// # Format detection
71///
72/// The deserializer auto-detects the config format:
73/// - **New format** (recommended): has a `generators` map keyed by generator ID.
74/// - **Legacy format**: uses `target`, `output`, `output_options`, and `client` fields.
75///   Automatically converted to the new format at load time.
76#[derive(Debug, Clone)]
77pub struct OagConfig {
78    /// Path to the OpenAPI spec file (YAML or JSON). Resolved relative to the
79    /// config file's directory. Can be overridden via `oag generate -i <path>`.
80    pub input: String,
81    /// Controls how operation names are derived from the spec.
82    pub naming: NamingConfig,
83    /// Map of generator ID → per-generator configuration. Only generators listed
84    /// here will run during `oag generate`. Order is preserved (insertion order).
85    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/// A generator identifier — any string that resolves to a template pack.
99///
100/// Built-in IDs:
101/// - `node-client` — TypeScript/Node API client
102/// - `react-swr-client` — React/SWR hooks
103/// - `fastapi-server` — Python FastAPI server stubs
104///
105/// Custom IDs resolve to template packs installed in the data directory
106/// or specified via an explicit path.
107#[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/// Configuration for a single generator.
124///
125/// Each entry in the `generators` map deserializes into this struct. All fields
126/// except `output` have sensible defaults so a minimal config only needs the
127/// output directory.
128///
129/// # TypeScript-only fields
130///
131/// `base_url`, `no_jsdoc`, and `source_dir` only affect the TypeScript generators
132/// (`node-client` and `react-swr-client`). They are silently ignored by `fastapi-server`.
133///
134/// # Scaffold
135///
136/// The `scaffold` field is an opaque JSON object forwarded to the generator.
137/// Each generator defines its own scaffold struct (e.g., package name, formatter,
138/// test runner, bundler). See the default config for available scaffold options.
139#[derive(Debug, Clone, Deserialize)]
140#[serde(default)]
141pub struct GeneratorConfig {
142    /// Output directory for generated files. Required — the generator writes all
143    /// files relative to this path. Created automatically if it doesn't exist.
144    pub output: String,
145    /// How files are organized on disk. Default: `modular`.
146    /// - `bundled` — single file containing all types, client, and SSE utilities
147    /// - `modular` — separate files per concern (types, client, sse, index)
148    /// - `split` — separate files per operation group (see `split_by`)
149    pub layout: OutputLayout,
150    /// Only used when `layout: split`. Controls how operations are grouped into files.
151    /// - `tag` (default) — one file per OpenAPI tag
152    /// - `operation` — one file per operation
153    /// - `route` — one file per route prefix
154    pub split_by: Option<SplitBy>,
155    /// Override the API base URL instead of reading it from the spec's `servers` array.
156    /// Only affects TypeScript generators. Useful when the spec doesn't include a server
157    /// or you need a different URL for development.
158    pub base_url: Option<String>,
159    /// Disable JSDoc comments on generated types and methods. Default: `false`.
160    /// Only affects TypeScript generators.
161    pub no_jsdoc: Option<bool>,
162    /// Subdirectory within `output` for generated source files. Default: `"src"`.
163    /// The scaffold's `tsconfig.json` and `tsdown.config.ts` adapt automatically.
164    /// Set to `""` to place source files directly at the output root.
165    /// Only affects TypeScript generators.
166    pub source_dir: String,
167    /// Opaque scaffold configuration forwarded to the generator. Each generator
168    /// defines its own scaffold options:
169    ///
170    /// **TypeScript generators** (`node-client`, `react-swr-client`):
171    /// - `package_name` — npm package name (default: derived from spec title)
172    /// - `repository` — repository URL for `package.json`
173    /// - `existing_repo` — skip all scaffold files, only emit source + root index
174    /// - `formatter` — `"biome"` or `false` to disable (default: `"biome"`)
175    /// - `test_runner` — `"vitest"` or `false` to disable (default: `"vitest"`)
176    /// - `bundler` — `"tsdown"` or `false` to disable (default: `"tsdown"`)
177    ///
178    /// **Python generator** (`fastapi-server`):
179    /// - `package_name` — Python package name for `pyproject.toml`
180    /// - `formatter` — `"ruff"` or `false` to disable (default: `"ruff"`)
181    /// - `test_runner` — `"pytest"` or `false` to disable (default: `"pytest"`)
182    ///
183    /// **All generators**:
184    /// - `extra_dev_dependencies` — map of additional dev dependency names to version
185    ///   specs, merged into the generated `devDependencies` (npm) or `[dependency-groups] dev`
186    ///   (Python). Example: `{ "@testing-library/react": "^16.0" }` for npm,
187    ///   `{ "factory-boy": ">=3.3" }` for Python.
188    ///
189    /// Set to `false` to explicitly disable all scaffolding. Omitting the field
190    /// entirely also disables scaffolding.
191    #[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/// How generated files are laid out on disk.
210///
211/// # YAML values
212///
213/// - `bundled` — everything in a single file (e.g., `src/index.ts`)
214/// - `modular` — separate files per concern (default, recommended)
215/// - `split` — separate files per operation group (requires `split_by`)
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
217#[serde(rename_all = "snake_case")]
218pub enum OutputLayout {
219    /// All types, client, and utilities concatenated into one output file.
220    /// Produces `src/index.ts` (TS) or a single combined Python module.
221    /// Scaffold files (package.json, tsconfig, etc.) are still separate.
222    Bundled,
223    /// Separate files per concern. This is the default and recommended layout.
224    /// TypeScript: `types.ts`, `client.ts`, `sse.ts`, `guards.ts`, `index.ts`.
225    /// Python: `models.py`, `routes.py`, `sse.py`, `app.py`.
226    Modular,
227    /// Separate files per operation group, determined by `split_by`.
228    /// For example, with `split_by: tag`: `src/pets.ts`, `src/users.ts`, etc.
229    /// Each file contains the types, client methods, and utilities for that group.
230    Split,
231}
232
233/// How to split operations into groups when using `OutputLayout::Split`.
234///
235/// # YAML values
236///
237/// - `operation` — one file per operation (finest granularity)
238/// - `tag` — one file per OpenAPI tag (default, recommended)
239/// - `route` — one file per route prefix (e.g., `/pets`, `/users`)
240#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
241#[serde(rename_all = "snake_case")]
242pub enum SplitBy {
243    /// One file per operation — finest granularity. Each operation gets its own
244    /// file with its types, client method, and utilities.
245    Operation,
246    /// One file per OpenAPI tag — groups related operations together.
247    /// Maps directly to `IrModule` groupings from the transform pipeline.
248    Tag,
249    /// One file per route prefix — groups operations sharing the same path root.
250    /// For example, `/pets` and `/pets/{petId}` would share a file.
251    Route,
252}
253
254/// Naming strategy and operation aliases.
255///
256/// Controls how operation names (used as function/method names in generated code)
257/// are derived from the OpenAPI spec.
258#[derive(Debug, Clone, Deserialize)]
259#[serde(default)]
260pub struct NamingConfig {
261    /// How to derive operation names. See [`NamingStrategy`] for options.
262    pub strategy: NamingStrategy,
263    /// Map from resolved operation name to a custom alias. Applied after the
264    /// naming strategy resolves the base name. Useful for shortening verbose
265    /// operationIds (e.g., `createChatCompletion` → `chat`).
266    #[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/// How operation names are derived from the OpenAPI spec.
280///
281/// # YAML values
282///
283/// - `use_operation_id` — use the `operationId` field directly (default)
284/// - `use_route_based` — derive from HTTP method + path (e.g., `GET /pets` → `getPets`)
285#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
286#[serde(rename_all = "snake_case")]
287pub enum NamingStrategy {
288    /// Use the `operationId` field from the OpenAPI spec as-is.
289    /// This is the default and works well when the spec has meaningful operation IDs.
290    /// Falls back to route-based naming if `operationId` is missing.
291    #[default]
292    UseOperationId,
293    /// Derive the name from the HTTP method and path.
294    /// For example: `GET /pets` → `getPets`, `POST /pets/{petId}` → `createPetsPetId`.
295    /// Useful when the spec lacks `operationId` fields or has inconsistent IDs.
296    UseRouteBased,
297}
298
299// --- Backward-compatible deserialization ---
300// Old format had: input, output, target, naming, output_options, client
301// New format has: input, naming, generators (map of GeneratorId -> GeneratorConfig)
302// We support both.
303
304/// Internal legacy config format for backward compat parsing.
305#[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/// Internal new-format config for forward parsing.
377#[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        // We deserialize into a generic map first to detect the format.
392        let value = serde_json::Value::deserialize(deserializer).map_err(de::Error::custom)?;
393
394        // Check if the config has a "generators" key — that's the new format.
395        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            // Legacy format
404            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            // In single layout with target=all, the old behavior was to put
447            // everything together using the React generator (which includes TS files).
448            // Map this to a single react-swr-client generator.
449            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
475/// Preferred config file name for new projects.
476pub const CONFIG_FILE_NAME: &str = "oag.yaml";
477
478/// Legacy config file name (deprecated, will be removed in a future release).
479pub const LEGACY_CONFIG_FILE: &str = ".urmzd.oag.yaml";
480
481/// Config file candidates, checked in priority order.
482pub const CONFIG_CANDIDATES: &[&str] = &["oag.yaml", "oag.yml", LEGACY_CONFIG_FILE];
483
484/// Find the first config file that exists in the given directory.
485/// Returns `(path, is_legacy)`.
486pub 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
497/// Load config from a YAML file. Returns `None` if the file doesn't exist.
498pub 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
513/// Generate the default config file content (new format).
514pub 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        // Single layout with "all" maps to react-swr-client (which includes TS)
642        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        // Legacy format with defaults: target=all, layout=single -> react-swr-client
714        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}