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.add_template(
97 "package.json.j2",
98 include_str!("../../templates/package.json.j2"),
99 )
100 .expect("template should be valid");
101 let tmpl = env.get_template("package.json.j2").unwrap();
102
103 let pkg_name = options
104 .package_name
105 .clone()
106 .unwrap_or_else(|| slugify(&options.name));
107
108 let biome = options.formatter.as_deref() == Some("biome");
109 let vitest = options.test_runner.as_deref() == Some("vitest");
110 let tsdown = options.bundler.as_deref() == Some("tsdown");
111
112 tmpl.render(context! {
113 name => pkg_name,
114 repository => options.repository,
115 react => options.react,
116 biome => biome,
117 vitest => vitest,
118 tsdown => tsdown,
119 })
120 .expect("render should succeed")
121}
122
123fn emit_tsconfig(options: &ScaffoldOptions) -> String {
124 let mut env = Environment::new();
125 env.add_template(
126 "tsconfig.json.j2",
127 include_str!("../../templates/tsconfig.json.j2"),
128 )
129 .expect("template should be valid");
130 let tmpl = env.get_template("tsconfig.json.j2").unwrap();
131
132 tmpl.render(context! {
133 react => options.react,
134 source_dir => options.source_dir,
135 })
136 .expect("render should succeed")
137}
138
139fn emit_biome() -> String {
140 include_str!("../../templates/biome.json.j2").to_string()
141}
142
143fn emit_tsdown(react: bool, source_dir: &str) -> String {
144 let mut env = Environment::new();
145 env.add_template(
146 "tsdown.config.ts.j2",
147 include_str!("../../templates/tsdown.config.ts.j2"),
148 )
149 .expect("template should be valid");
150 let tmpl = env.get_template("tsdown.config.ts.j2").unwrap();
151
152 tmpl.render(context! {
153 react => react,
154 source_dir => source_dir,
155 })
156 .expect("render should succeed")
157}
158
159fn slugify(title: &str) -> String {
161 let slug: String = title
162 .chars()
163 .map(|c| {
164 if c.is_alphanumeric() {
165 c.to_ascii_lowercase()
166 } else {
167 '-'
168 }
169 })
170 .collect();
171
172 let mut result = String::new();
174 let mut prev_dash = false;
175 for c in slug.chars() {
176 if c == '-' {
177 if !prev_dash && !result.is_empty() {
178 result.push('-');
179 }
180 prev_dash = true;
181 } else {
182 result.push(c);
183 prev_dash = false;
184 }
185 }
186
187 result.trim_end_matches('-').to_string()
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 #[test]
195 fn test_slugify() {
196 assert_eq!(slugify("My API Service"), "my-api-service");
197 assert_eq!(slugify("SSE Chat API"), "sse-chat-api");
198 assert_eq!(slugify("Petstore - OpenAPI 3.2"), "petstore-openapi-3-2");
199 }
200
201 #[test]
202 fn test_emit_scaffold_with_all_options() {
203 let options = ScaffoldOptions {
204 name: "Test API".to_string(),
205 package_name: None,
206 repository: Some("https://github.com/test/repo".to_string()),
207 formatter: Some("biome".to_string()),
208 bundler: Some("tsdown".to_string()),
209 test_runner: Some("vitest".to_string()),
210 react: true,
211 existing_repo: false,
212 source_dir: "src".to_string(),
213 };
214 let files = emit_scaffold(&options);
215 assert_eq!(files.len(), 4);
216 assert!(files.iter().any(|f| f.path == "package.json"));
217 assert!(files.iter().any(|f| f.path == "tsconfig.json"));
218 assert!(files.iter().any(|f| f.path == "biome.json"));
219 assert!(files.iter().any(|f| f.path == "tsdown.config.ts"));
220 }
221
222 #[test]
223 fn test_emit_scaffold_minimal() {
224 let options = ScaffoldOptions {
225 name: "Test".to_string(),
226 package_name: None,
227 repository: None,
228 formatter: None,
229 bundler: None,
230 test_runner: None,
231 react: false,
232 existing_repo: false,
233 source_dir: "src".to_string(),
234 };
235 let files = emit_scaffold(&options);
236 assert_eq!(files.len(), 2); }
238
239 #[test]
240 fn test_custom_package_name() {
241 let options = ScaffoldOptions {
242 name: "Some API".to_string(),
243 package_name: Some("@myorg/api-client".to_string()),
244 repository: None,
245 formatter: None,
246 bundler: None,
247 test_runner: None,
248 react: false,
249 existing_repo: false,
250 source_dir: "src".to_string(),
251 };
252 let files = emit_scaffold(&options);
253 let pkg = files.iter().find(|f| f.path == "package.json").unwrap();
254 assert!(pkg.content.contains("@myorg/api-client"));
255 }
256}