1use std::{collections::HashSet, path::Path};
4
5use baobao_codegen::{
6 CommandInfo, CommandTree, ContextFieldInfo, GenerateResult, HandlerPaths, LanguageCodegen,
7 PoolConfigInfo, PreviewFile, SqliteConfigInfo,
8};
9use baobao_core::{
10 ContextFieldType, DatabaseType, GeneratedFile, to_camel_case, to_kebab_case, to_pascal_case,
11 toml_value_to_string,
12};
13use baobao_manifest::{ArgType, Command, Language, Manifest};
14use eyre::Result;
15
16use crate::files::{
17 BaoToml, CliTs, CommandTs, ContextTs, GitIgnore, HandlerTs, IndexTs, PackageJson, TsConfig,
18};
19
20pub struct Generator<'a> {
22 schema: &'a Manifest,
23}
24
25impl LanguageCodegen for Generator<'_> {
26 fn language(&self) -> &'static str {
27 "typescript"
28 }
29
30 fn file_extension(&self) -> &'static str {
31 "ts"
32 }
33
34 fn preview(&self) -> Vec<PreviewFile> {
35 self.preview_files()
36 }
37
38 fn generate(&self, output_dir: &Path) -> Result<GenerateResult> {
39 self.generate_files(output_dir)
40 }
41}
42
43impl<'a> Generator<'a> {
44 pub fn new(schema: &'a Manifest) -> Self {
45 Self { schema }
46 }
47
48 fn preview_files(&self) -> Vec<PreviewFile> {
50 let mut files = Vec::new();
51
52 let context_fields = self.collect_context_fields();
54
55 files.push(PreviewFile {
57 path: "src/context.ts".to_string(),
58 content: ContextTs::new(context_fields).render(),
59 });
60
61 files.push(PreviewFile {
63 path: "src/index.ts".to_string(),
64 content: IndexTs.render(),
65 });
66
67 files.push(PreviewFile {
69 path: "package.json".to_string(),
70 content: PackageJson::new(&self.schema.cli.name)
71 .with_version(self.schema.cli.version.clone())
72 .render(),
73 });
74
75 files.push(PreviewFile {
77 path: "tsconfig.json".to_string(),
78 content: TsConfig.render(),
79 });
80
81 files.push(PreviewFile {
83 path: ".gitignore".to_string(),
84 content: GitIgnore.render(),
85 });
86
87 let commands: Vec<CommandInfo> = self
89 .schema
90 .commands
91 .iter()
92 .map(|(name, cmd)| CommandInfo {
93 name: name.clone(),
94 description: cmd.description.clone(),
95 has_subcommands: cmd.has_subcommands(),
96 })
97 .collect();
98
99 files.push(PreviewFile {
100 path: "src/cli.ts".to_string(),
101 content: CliTs::new(
102 &self.schema.cli.name,
103 self.schema.cli.version.clone(),
104 self.schema.cli.description.clone(),
105 commands,
106 )
107 .render(),
108 });
109
110 for (name, command) in &self.schema.commands {
112 let content = self.generate_command_file(name, command);
113 let file_name = to_kebab_case(name);
114 files.push(PreviewFile {
115 path: format!("src/commands/{}.ts", file_name),
116 content: CommandTs::new(name, content).render(),
117 });
118 }
119
120 files
121 }
122
123 fn generate_files(&self, output_dir: &Path) -> Result<GenerateResult> {
125 let handlers_dir = output_dir.join("src").join("handlers");
126
127 let context_fields = self.collect_context_fields();
129
130 ContextTs::new(context_fields).write(output_dir)?;
132
133 IndexTs.write(output_dir)?;
135
136 PackageJson::new(&self.schema.cli.name)
138 .with_version(self.schema.cli.version.clone())
139 .write(output_dir)?;
140
141 TsConfig.write(output_dir)?;
143
144 GitIgnore.write(output_dir)?;
146
147 BaoToml::new(&self.schema.cli.name, Language::TypeScript)
149 .with_version(self.schema.cli.version.clone())
150 .write(output_dir)?;
151
152 let commands: Vec<CommandInfo> = self
154 .schema
155 .commands
156 .iter()
157 .map(|(name, cmd)| CommandInfo {
158 name: name.clone(),
159 description: cmd.description.clone(),
160 has_subcommands: cmd.has_subcommands(),
161 })
162 .collect();
163
164 CliTs::new(
165 &self.schema.cli.name,
166 self.schema.cli.version.clone(),
167 self.schema.cli.description.clone(),
168 commands,
169 )
170 .write(output_dir)?;
171
172 std::fs::create_dir_all(output_dir.join("src").join("commands"))?;
174
175 for (name, command) in &self.schema.commands {
177 let content = self.generate_command_file(name, command);
178 CommandTs::new(name, content).write(output_dir)?;
179 }
180
181 let result = self.generate_handlers(&handlers_dir, output_dir)?;
183
184 Ok(result)
185 }
186
187 fn collect_context_fields(&self) -> Vec<ContextFieldInfo> {
188 use baobao_manifest::ContextField;
189
190 self.schema
191 .context
192 .fields()
193 .into_iter()
194 .map(|(name, field)| {
195 let env_var = field
196 .env()
197 .map(|s| s.to_string())
198 .unwrap_or_else(|| field.default_env().to_string());
199
200 let pool = field
201 .pool_config()
202 .map(|p| PoolConfigInfo {
203 max_connections: p.max_connections,
204 min_connections: p.min_connections,
205 acquire_timeout: p.acquire_timeout,
206 idle_timeout: p.idle_timeout,
207 max_lifetime: p.max_lifetime,
208 })
209 .unwrap_or_default();
210
211 let sqlite = field.sqlite_config().map(|s| SqliteConfigInfo {
212 path: s.path.clone(),
213 create_if_missing: s.create_if_missing,
214 read_only: s.read_only,
215 journal_mode: s.journal_mode.as_ref().map(|m| m.as_str().to_string()),
216 synchronous: s.synchronous.as_ref().map(|m| m.as_str().to_string()),
217 busy_timeout: s.busy_timeout,
218 foreign_keys: s.foreign_keys,
219 });
220
221 let field_type = match &field {
222 ContextField::Postgres(_) => ContextFieldType::Database(DatabaseType::Postgres),
223 ContextField::Mysql(_) => ContextFieldType::Database(DatabaseType::Mysql),
224 ContextField::Sqlite(_) => ContextFieldType::Database(DatabaseType::Sqlite),
225 ContextField::Http(_) => ContextFieldType::Http,
226 };
227
228 ContextFieldInfo {
229 name: name.to_string(),
230 field_type,
231 env_var,
232 is_async: field.is_async(),
233 pool,
234 sqlite,
235 }
236 })
237 .collect()
238 }
239
240 fn generate_command_file(&self, name: &str, command: &Command) -> String {
241 let pascal_name = to_pascal_case(name);
242 let camel_name = to_camel_case(name);
243 let kebab_name = to_kebab_case(name);
244
245 let mut code = String::new();
246
247 code.push_str("import { command } from \"boune\";\n");
249 code.push_str(&format!(
250 "import {{ run }} from \"../handlers/{}.ts\";\n",
251 kebab_name
252 ));
253 code.push_str("import type { Context } from \"../context.ts\";\n\n");
254
255 code.push_str(&self.generate_args_interface(&pascal_name, command));
257 code.push('\n');
258
259 code.push_str(&self.generate_command_definition(&pascal_name, &camel_name, name, command));
261
262 code
263 }
264
265 fn generate_args_interface(&self, pascal_name: &str, command: &Command) -> String {
266 let mut code = format!("export interface {}Args {{\n", pascal_name);
267
268 for (arg_name, arg) in &command.args {
270 let ts_type = self.map_arg_type(&arg.arg_type);
271 let camel_name = to_camel_case(arg_name);
272 if arg.required && arg.default.is_none() {
273 code.push_str(&format!(" {}: {};\n", camel_name, ts_type));
274 } else {
275 code.push_str(&format!(" {}?: {};\n", camel_name, ts_type));
276 }
277 }
278
279 for (flag_name, flag) in &command.flags {
281 let ts_type = self.map_arg_type(&flag.flag_type);
282 let camel_name = to_camel_case(flag_name);
283 if flag.flag_type == ArgType::Bool || flag.default.is_some() {
285 code.push_str(&format!(" {}: {};\n", camel_name, ts_type));
286 } else {
287 code.push_str(&format!(" {}?: {};\n", camel_name, ts_type));
288 }
289 }
290
291 code.push_str("}\n");
292 code
293 }
294
295 fn generate_command_definition(
296 &self,
297 pascal_name: &str,
298 camel_name: &str,
299 name: &str,
300 command: &Command,
301 ) -> String {
302 let mut code = format!(
303 "export const {}Command = command(\"{}\")\n",
304 camel_name, name
305 );
306 code.push_str(&format!(" .description(\"{}\")\n", command.description));
307
308 for (arg_name, arg) in &command.args {
310 let bracket = if arg.required && arg.default.is_none() {
311 format!("<{}>", arg_name)
312 } else {
313 format!("[{}]", arg_name)
314 };
315 let desc = arg.description.as_deref().unwrap_or("");
316
317 if arg.arg_type != ArgType::String {
318 code.push_str(&format!(
319 " .argument(\"{}\", \"{}\", {{ type: \"{}\" }})\n",
320 bracket,
321 desc,
322 self.map_boune_type(&arg.arg_type)
323 ));
324 } else {
325 code.push_str(&format!(" .argument(\"{}\", \"{}\")\n", bracket, desc));
326 }
327 }
328
329 for (flag_name, flag) in &command.flags {
331 let short_part = flag
332 .short_char()
333 .map(|c| format!("-{}, ", c))
334 .unwrap_or_default();
335 let desc = flag.description.as_deref().unwrap_or("");
336
337 if flag.flag_type == ArgType::Bool {
338 code.push_str(&format!(
339 " .option(\"{}--{}\", \"{}\")\n",
340 short_part, flag_name, desc
341 ));
342 } else {
343 let default_part = flag.default.as_ref().map_or(String::new(), |d| {
344 format!(", default: {}", toml_value_to_string(d))
345 });
346 code.push_str(&format!(
347 " .option(\"{}--{} <{}>\", \"{}\", {{ type: \"{}\"{} }})\n",
348 short_part,
349 flag_name,
350 flag_name,
351 desc,
352 self.map_boune_type(&flag.flag_type),
353 default_part
354 ));
355 }
356 }
357
358 code.push_str(" .action(async ({ args, options }) => {\n");
360 code.push_str(&format!(" const typedArgs: {}Args = {{\n", pascal_name));
361
362 for arg_name in command.args.keys() {
364 let camel = to_camel_case(arg_name);
365 code.push_str(&format!(
366 " {}: args.{} as {}Args[\"{}\"],\n",
367 camel, arg_name, pascal_name, camel
368 ));
369 }
370
371 for (flag_name, flag) in &command.flags {
373 let camel = to_camel_case(flag_name);
374 if flag.flag_type == ArgType::Bool {
375 code.push_str(&format!(
376 " {}: (options.{} as boolean) ?? false,\n",
377 camel, flag_name
378 ));
379 } else {
380 code.push_str(&format!(
381 " {}: options.{} as {}Args[\"{}\"],\n",
382 camel, flag_name, pascal_name, camel
383 ));
384 }
385 }
386
387 code.push_str(" };\n");
388 code.push_str(" await run({} as Context, typedArgs);\n");
389 code.push_str(" });\n");
390
391 code
392 }
393
394 fn map_arg_type(&self, arg_type: &ArgType) -> &'static str {
395 match arg_type {
396 ArgType::String => "string",
397 ArgType::Int => "number",
398 ArgType::Float => "number",
399 ArgType::Bool => "boolean",
400 ArgType::Path => "string",
401 }
402 }
403
404 fn map_boune_type(&self, arg_type: &ArgType) -> &'static str {
405 match arg_type {
406 ArgType::String => "string",
407 ArgType::Int => "number",
408 ArgType::Float => "number",
409 ArgType::Bool => "boolean",
410 ArgType::Path => "string",
411 }
412 }
413
414 fn generate_handlers(&self, handlers_dir: &Path, _output_dir: &Path) -> Result<GenerateResult> {
416 let mut created_handlers = Vec::new();
417
418 let expected_handlers: HashSet<String> = CommandTree::new(self.schema)
420 .collect_paths()
421 .into_iter()
422 .map(|path| {
423 path.split('/')
424 .map(to_kebab_case)
425 .collect::<Vec<_>>()
426 .join("/")
427 })
428 .collect();
429
430 std::fs::create_dir_all(handlers_dir)?;
432
433 for (name, command) in &self.schema.commands {
435 let created = self.generate_handler_stubs(handlers_dir, name, command, "")?;
436 created_handlers.extend(created);
437 }
438
439 let handler_paths = HandlerPaths::new(handlers_dir, "ts");
441 let orphan_handlers = handler_paths.find_orphans(&expected_handlers)?;
442
443 Ok(GenerateResult {
444 created_handlers,
445 orphan_handlers,
446 })
447 }
448
449 fn generate_handler_stubs(
451 &self,
452 handlers_dir: &Path,
453 name: &str,
454 command: &Command,
455 prefix: &str,
456 ) -> Result<Vec<String>> {
457 use baobao_core::WriteResult;
458
459 let mut created = Vec::new();
460
461 let kebab_name = to_kebab_case(name);
462 let display_path = if prefix.is_empty() {
463 kebab_name.clone()
464 } else {
465 format!("{}/{}", prefix, kebab_name)
466 };
467
468 if command.has_subcommands() {
469 let subdir = handlers_dir.join(&kebab_name);
471 std::fs::create_dir_all(&subdir)?;
472
473 for (sub_name, sub_command) in &command.commands {
475 let new_prefix = if prefix.is_empty() {
476 kebab_name.clone()
477 } else {
478 format!("{}/{}", prefix, kebab_name)
479 };
480 let sub_created =
481 self.generate_handler_stubs(&subdir, sub_name, sub_command, &new_prefix)?;
482 created.extend(sub_created);
483 }
484 } else {
485 let pascal_name = to_pascal_case(name);
487 let stub = HandlerTs::new(name, format!("{}Args", pascal_name));
488 let result = stub.write(handlers_dir)?;
489
490 if matches!(result, WriteResult::Written) {
491 created.push(format!("{}.ts", display_path));
492 }
493 }
494
495 Ok(created)
496 }
497}