Skip to main content

oag_node_client/emitters/
scaffold.rs

1use minijinja::{Environment, context};
2use oag_core::GeneratedFile;
3use oag_core::config::ToolSetting;
4use serde::Deserialize;
5
6/// Node/TS-specific scaffold configuration, parsed from the opaque `serde_json::Value`.
7#[derive(Debug, Clone, Default, Deserialize)]
8#[serde(default)]
9pub struct NodeScaffoldConfig {
10    pub package_name: Option<String>,
11    pub repository: Option<String>,
12    pub index: Option<bool>,
13    pub formatter: Option<ToolSetting>,
14    pub test_runner: Option<ToolSetting>,
15    pub bundler: Option<ToolSetting>,
16    pub existing_repo: Option<bool>,
17}
18
19/// Options controlling which scaffold files to generate.
20#[derive(Debug, Clone)]
21pub struct ScaffoldOptions {
22    /// Spec title, used as fallback for package name.
23    pub name: String,
24    /// Custom package name override (if None, derives from spec title).
25    pub package_name: Option<String>,
26    /// Repository URL for package.json.
27    pub repository: Option<String>,
28    /// Formatter tool name (e.g. "biome") or None if disabled.
29    pub formatter: Option<String>,
30    /// Test runner tool name (e.g. "vitest") or None if disabled.
31    pub test_runner: Option<String>,
32    /// Bundler tool name (e.g. "tsdown") or None if disabled.
33    pub bundler: Option<String>,
34    /// Whether React target is included.
35    pub react: bool,
36    /// Whether generating into an existing repo (skip all scaffold files).
37    pub existing_repo: bool,
38}
39
40/// Generate project scaffold files (package.json, tsconfig.json, biome.json, tsdown.config.ts).
41/// When `existing_repo` is true, only a root-level `index.ts` re-export is generated;
42/// all other scaffold files are skipped.
43pub fn emit_scaffold(options: &ScaffoldOptions) -> Vec<GeneratedFile> {
44    if options.existing_repo {
45        return vec![GeneratedFile {
46            path: "index.ts".to_string(),
47            content: "// Auto-generated by oag — do not edit\nexport * from \"./src/index\";\n"
48                .to_string(),
49        }];
50    }
51
52    let mut files = Vec::new();
53
54    // package.json
55    files.push(GeneratedFile {
56        path: "package.json".to_string(),
57        content: emit_package_json(options),
58    });
59
60    // tsconfig.json
61    files.push(GeneratedFile {
62        path: "tsconfig.json".to_string(),
63        content: emit_tsconfig(options),
64    });
65
66    // biome.json (optional)
67    if options.formatter.as_deref() == Some("biome") {
68        files.push(GeneratedFile {
69            path: "biome.json".to_string(),
70            content: emit_biome(),
71        });
72    }
73
74    // tsdown.config.ts (optional)
75    if options.bundler.as_deref() == Some("tsdown") {
76        files.push(GeneratedFile {
77            path: "tsdown.config.ts".to_string(),
78            content: emit_tsdown(),
79        });
80    }
81
82    files
83}
84
85fn emit_package_json(options: &ScaffoldOptions) -> String {
86    let mut env = Environment::new();
87    env.add_template(
88        "package.json.j2",
89        include_str!("../../templates/package.json.j2"),
90    )
91    .expect("template should be valid");
92    let tmpl = env.get_template("package.json.j2").unwrap();
93
94    let pkg_name = options
95        .package_name
96        .clone()
97        .unwrap_or_else(|| slugify(&options.name));
98
99    let biome = options.formatter.as_deref() == Some("biome");
100    let vitest = options.test_runner.as_deref() == Some("vitest");
101    let tsdown = options.bundler.as_deref() == Some("tsdown");
102
103    tmpl.render(context! {
104        name => pkg_name,
105        repository => options.repository,
106        react => options.react,
107        biome => biome,
108        vitest => vitest,
109        tsdown => tsdown,
110    })
111    .expect("render should succeed")
112}
113
114fn emit_tsconfig(options: &ScaffoldOptions) -> String {
115    let mut env = Environment::new();
116    env.add_template(
117        "tsconfig.json.j2",
118        include_str!("../../templates/tsconfig.json.j2"),
119    )
120    .expect("template should be valid");
121    let tmpl = env.get_template("tsconfig.json.j2").unwrap();
122
123    tmpl.render(context! {
124        react => options.react,
125    })
126    .expect("render should succeed")
127}
128
129fn emit_biome() -> String {
130    include_str!("../../templates/biome.json.j2").to_string()
131}
132
133fn emit_tsdown() -> String {
134    include_str!("../../templates/tsdown.config.ts.j2").to_string()
135}
136
137/// Convert a title to a kebab-case package name.
138fn slugify(title: &str) -> String {
139    let slug: String = title
140        .chars()
141        .map(|c| {
142            if c.is_alphanumeric() {
143                c.to_ascii_lowercase()
144            } else {
145                '-'
146            }
147        })
148        .collect();
149
150    // Collapse consecutive dashes and trim
151    let mut result = String::new();
152    let mut prev_dash = false;
153    for c in slug.chars() {
154        if c == '-' {
155            if !prev_dash && !result.is_empty() {
156                result.push('-');
157            }
158            prev_dash = true;
159        } else {
160            result.push(c);
161            prev_dash = false;
162        }
163    }
164
165    result.trim_end_matches('-').to_string()
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn test_slugify() {
174        assert_eq!(slugify("My API Service"), "my-api-service");
175        assert_eq!(slugify("SSE Chat API"), "sse-chat-api");
176        assert_eq!(slugify("Petstore - OpenAPI 3.2"), "petstore-openapi-3-2");
177    }
178
179    #[test]
180    fn test_emit_scaffold_with_all_options() {
181        let options = ScaffoldOptions {
182            name: "Test API".to_string(),
183            package_name: None,
184            repository: Some("https://github.com/test/repo".to_string()),
185            formatter: Some("biome".to_string()),
186            bundler: Some("tsdown".to_string()),
187            test_runner: Some("vitest".to_string()),
188            react: true,
189            existing_repo: false,
190        };
191        let files = emit_scaffold(&options);
192        assert_eq!(files.len(), 4);
193        assert!(files.iter().any(|f| f.path == "package.json"));
194        assert!(files.iter().any(|f| f.path == "tsconfig.json"));
195        assert!(files.iter().any(|f| f.path == "biome.json"));
196        assert!(files.iter().any(|f| f.path == "tsdown.config.ts"));
197    }
198
199    #[test]
200    fn test_emit_scaffold_minimal() {
201        let options = ScaffoldOptions {
202            name: "Test".to_string(),
203            package_name: None,
204            repository: None,
205            formatter: None,
206            bundler: None,
207            test_runner: None,
208            react: false,
209            existing_repo: false,
210        };
211        let files = emit_scaffold(&options);
212        assert_eq!(files.len(), 2); // Only package.json + tsconfig.json
213    }
214
215    #[test]
216    fn test_custom_package_name() {
217        let options = ScaffoldOptions {
218            name: "Some API".to_string(),
219            package_name: Some("@myorg/api-client".to_string()),
220            repository: None,
221            formatter: None,
222            bundler: None,
223            test_runner: None,
224            react: false,
225            existing_repo: false,
226        };
227        let files = emit_scaffold(&options);
228        let pkg = files.iter().find(|f| f.path == "package.json").unwrap();
229        assert!(pkg.content.contains("@myorg/api-client"));
230    }
231}