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 pub source_dir: String,
40}
41
42pub fn emit_scaffold(options: &ScaffoldOptions) -> Vec<GeneratedFile> {
46 if options.existing_repo {
47 let ext = if options.react { "tsx" } else { "ts" };
48 let import_path = if options.source_dir.is_empty() {
49 "./index".to_string()
50 } else {
51 format!("./{}/index", options.source_dir)
52 };
53 return vec![GeneratedFile {
54 path: format!("index.{ext}"),
55 content: format!(
56 "// Auto-generated by oag — do not edit\nexport * from \"{import_path}\";\n"
57 ),
58 }];
59 }
60
61 let mut files = Vec::new();
62
63 files.push(GeneratedFile {
65 path: "package.json".to_string(),
66 content: emit_package_json(options),
67 });
68
69 files.push(GeneratedFile {
71 path: "tsconfig.json".to_string(),
72 content: emit_tsconfig(options),
73 });
74
75 if options.formatter.as_deref() == Some("biome") {
77 files.push(GeneratedFile {
78 path: "biome.json".to_string(),
79 content: emit_biome(),
80 });
81 }
82
83 if options.bundler.as_deref() == Some("tsdown") {
85 files.push(GeneratedFile {
86 path: "tsdown.config.ts".to_string(),
87 content: emit_tsdown(options.react, &options.source_dir),
88 });
89 }
90
91 files
92}
93
94fn emit_package_json(options: &ScaffoldOptions) -> String {
95 let mut env = Environment::new();
96 env.set_trim_blocks(true);
97 env.add_template(
98 "package.json.j2",
99 include_str!("../../templates/package.json.j2"),
100 )
101 .expect("template should be valid");
102 let tmpl = env.get_template("package.json.j2").unwrap();
103
104 let pkg_name = options
105 .package_name
106 .clone()
107 .unwrap_or_else(|| slugify(&options.name));
108
109 let biome = options.formatter.as_deref() == Some("biome");
110 let vitest = options.test_runner.as_deref() == Some("vitest");
111 let tsdown = options.bundler.as_deref() == Some("tsdown");
112
113 tmpl.render(context! {
114 name => pkg_name,
115 repository => options.repository,
116 react => options.react,
117 biome => biome,
118 vitest => vitest,
119 tsdown => tsdown,
120 })
121 .expect("render should succeed")
122}
123
124fn emit_tsconfig(options: &ScaffoldOptions) -> String {
125 let mut env = Environment::new();
126 env.add_template(
127 "tsconfig.json.j2",
128 include_str!("../../templates/tsconfig.json.j2"),
129 )
130 .expect("template should be valid");
131 let tmpl = env.get_template("tsconfig.json.j2").unwrap();
132
133 tmpl.render(context! {
134 react => options.react,
135 source_dir => options.source_dir,
136 })
137 .expect("render should succeed")
138}
139
140fn emit_biome() -> String {
141 include_str!("../../templates/biome.json.j2").to_string()
142}
143
144fn emit_tsdown(react: bool, source_dir: &str) -> String {
145 let mut env = Environment::new();
146 env.add_template(
147 "tsdown.config.ts.j2",
148 include_str!("../../templates/tsdown.config.ts.j2"),
149 )
150 .expect("template should be valid");
151 let tmpl = env.get_template("tsdown.config.ts.j2").unwrap();
152
153 tmpl.render(context! {
154 react => react,
155 source_dir => source_dir,
156 })
157 .expect("render should succeed")
158}
159
160fn slugify(title: &str) -> String {
162 let slug: String = title
163 .chars()
164 .map(|c| {
165 if c.is_alphanumeric() {
166 c.to_ascii_lowercase()
167 } else {
168 '-'
169 }
170 })
171 .collect();
172
173 let mut result = String::new();
175 let mut prev_dash = false;
176 for c in slug.chars() {
177 if c == '-' {
178 if !prev_dash && !result.is_empty() {
179 result.push('-');
180 }
181 prev_dash = true;
182 } else {
183 result.push(c);
184 prev_dash = false;
185 }
186 }
187
188 result.trim_end_matches('-').to_string()
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 #[test]
196 fn test_slugify() {
197 assert_eq!(slugify("My API Service"), "my-api-service");
198 assert_eq!(slugify("SSE Chat API"), "sse-chat-api");
199 assert_eq!(slugify("Petstore - OpenAPI 3.2"), "petstore-openapi-3-2");
200 }
201
202 #[test]
203 fn test_emit_scaffold_with_all_options() {
204 let options = ScaffoldOptions {
205 name: "Test API".to_string(),
206 package_name: None,
207 repository: Some("https://github.com/test/repo".to_string()),
208 formatter: Some("biome".to_string()),
209 bundler: Some("tsdown".to_string()),
210 test_runner: Some("vitest".to_string()),
211 react: true,
212 existing_repo: false,
213 source_dir: "src".to_string(),
214 };
215 let files = emit_scaffold(&options);
216 assert_eq!(files.len(), 4);
217 assert!(files.iter().any(|f| f.path == "package.json"));
218 assert!(files.iter().any(|f| f.path == "tsconfig.json"));
219 assert!(files.iter().any(|f| f.path == "biome.json"));
220 assert!(files.iter().any(|f| f.path == "tsdown.config.ts"));
221 }
222
223 #[test]
224 fn test_emit_scaffold_minimal() {
225 let options = ScaffoldOptions {
226 name: "Test".to_string(),
227 package_name: None,
228 repository: None,
229 formatter: None,
230 bundler: None,
231 test_runner: None,
232 react: false,
233 existing_repo: false,
234 source_dir: "src".to_string(),
235 };
236 let files = emit_scaffold(&options);
237 assert_eq!(files.len(), 2); }
239
240 #[test]
241 fn test_custom_package_name() {
242 let options = ScaffoldOptions {
243 name: "Some API".to_string(),
244 package_name: Some("@myorg/api-client".to_string()),
245 repository: None,
246 formatter: None,
247 bundler: None,
248 test_runner: None,
249 react: false,
250 existing_repo: false,
251 source_dir: "src".to_string(),
252 };
253 let files = emit_scaffold(&options);
254 let pkg = files.iter().find(|f| f.path == "package.json").unwrap();
255 assert!(pkg.content.contains("@myorg/api-client"));
256 }
257}