oag_node_client/emitters/
scaffold.rs1use minijinja::{Environment, context};
2use oag_core::GeneratedFile;
3use oag_core::config::ToolSetting;
4use serde::Deserialize;
5
6#[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#[derive(Debug, Clone)]
21pub struct ScaffoldOptions {
22 pub name: String,
24 pub package_name: Option<String>,
26 pub repository: Option<String>,
28 pub formatter: Option<String>,
30 pub test_runner: Option<String>,
32 pub bundler: Option<String>,
34 pub react: bool,
36 pub existing_repo: bool,
38}
39
40pub 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 files.push(GeneratedFile {
56 path: "package.json".to_string(),
57 content: emit_package_json(options),
58 });
59
60 files.push(GeneratedFile {
62 path: "tsconfig.json".to_string(),
63 content: emit_tsconfig(options),
64 });
65
66 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 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
137fn 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 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); }
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}