1use std::fmt;
2
3use std::path::PathBuf;
4
5use heck::AsPascalCase;
6use indexmap::IndexMap;
7
8use crate::spec::cmd::SpecCommand;
9use crate::Spec;
10
11pub mod python;
12pub mod typescript;
13
14#[derive(Debug, Clone)]
15pub enum SdkLanguage {
16 TypeScript,
17 Python,
18}
19
20#[derive(Debug, Clone)]
21pub struct SdkOptions {
22 pub language: SdkLanguage,
23 pub package_name: Option<String>,
24 pub source_file: Option<String>,
25}
26
27#[derive(Debug)]
28pub struct SdkOutput {
29 pub files: Vec<SdkFile>,
30}
31
32#[derive(Debug)]
33pub struct SdkFile {
34 pub path: PathBuf,
35 pub content: String,
36}
37
38pub fn generate(spec: &Spec, opts: &SdkOptions) -> SdkOutput {
39 match opts.language {
40 SdkLanguage::TypeScript => typescript::generate(spec, opts),
41 SdkLanguage::Python => python::generate(spec, opts),
42 }
43}
44
45pub(crate) fn escape_jsdoc(s: &str) -> String {
47 s.replace("*/", r"*\/")
48}
49
50pub(crate) fn escape_py_docstring(s: &str) -> String {
52 s.replace('\\', r"\\").replace(r#"""""#, r#"\"\"\""#)
53}
54
55pub(crate) fn escape_py_string(s: &str) -> String {
57 s.replace('\\', r"\\").replace('"', r#"\""#)
58}
59
60pub(crate) fn escape_ts_string(s: &str) -> String {
62 s.replace('\\', r"\\").replace('"', r#"\""#)
63}
64
65pub(crate) struct CodeWriter {
67 buf: String,
68 indent: usize,
69 indent_str: &'static str,
70}
71
72impl CodeWriter {
73 pub fn new() -> Self {
74 Self {
75 buf: String::new(),
76 indent: 0,
77 indent_str: " ",
78 }
79 }
80
81 pub fn with_indent(indent_str: &'static str) -> Self {
83 Self {
84 buf: String::new(),
85 indent: 0,
86 indent_str,
87 }
88 }
89
90 pub fn line(&mut self, s: &str) {
91 if !s.is_empty() {
92 for _ in 0..self.indent {
93 self.buf.push_str(self.indent_str);
94 }
95 }
96 self.buf.push_str(s);
97 self.buf.push('\n');
98 }
99
100 pub fn indent(&mut self) {
101 self.indent += 1;
102 }
103
104 pub fn dedent(&mut self) {
105 self.indent = self.indent.saturating_sub(1);
106 }
107
108 pub fn finish(self) -> String {
109 self.buf
110 }
111}
112
113impl fmt::Display for CodeWriter {
114 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115 write!(f, "{}", self.buf)
116 }
117}
118
119pub(crate) fn generated_header(comment_prefix: &str, source: &Option<String>) -> String {
120 match source {
121 Some(s) => {
122 format!("{comment_prefix} @generated by usage-cli from {s}. Do not edit manually.")
123 }
124 None => format!("{comment_prefix} @generated by usage-cli. Do not edit manually."),
125 }
126}
127
128pub(crate) fn command_type_name(cmd: &SpecCommand, package_name: &str) -> String {
130 if cmd.name.is_empty() {
131 AsPascalCase(package_name).to_string()
132 } else {
133 AsPascalCase(&cmd.name).to_string()
134 }
135}
136
137pub(crate) struct ChoiceTypeMap {
146 pub types: IndexMap<String, Vec<String>>,
148 name_map: IndexMap<(String, String), String>,
150}
151
152impl ChoiceTypeMap {
153 pub fn lookup(&self, cmd_name: &str, item_name: &str) -> Option<&str> {
157 self.name_map
158 .get(&(cmd_name.to_string(), item_name.to_string()))
159 .map(|s| s.as_str())
160 }
161
162 pub fn iter(&self) -> indexmap::map::Iter<'_, String, Vec<String>> {
164 self.types.iter()
165 }
166
167 pub fn is_empty(&self) -> bool {
169 self.types.is_empty()
170 }
171}
172
173struct ChoiceEntry {
174 base_name: String,
175 item_name: String,
176 cmd_name: String,
177 cmd_prefix: String,
178 choices: Vec<String>,
179}
180
181pub(crate) fn collect_choice_types(cmd: &SpecCommand) -> ChoiceTypeMap {
185 let mut all_entries: Vec<ChoiceEntry> = Vec::new();
186 collect_choice_entries(cmd, &mut all_entries);
187
188 let mut base_groups: IndexMap<String, Vec<&ChoiceEntry>> = IndexMap::new();
190 for entry in &all_entries {
191 base_groups
192 .entry(entry.base_name.clone())
193 .or_default()
194 .push(entry);
195 }
196
197 let mut types = IndexMap::new();
198 let mut name_map = IndexMap::new();
199 for (base_name, entries) in &base_groups {
200 let all_same = entries.windows(2).all(|w| w[0].choices == w[1].choices);
201 if all_same {
202 types.insert(base_name.clone(), entries[0].choices.clone());
203 for entry in entries {
204 name_map.insert(
205 (entry.cmd_name.clone(), entry.item_name.clone()),
206 base_name.clone(),
207 );
208 }
209 } else {
210 for entry in entries {
211 let prefixed = format!("{}{}", entry.cmd_prefix, base_name);
212 types.insert(prefixed.clone(), entry.choices.clone());
213 name_map.insert((entry.cmd_name.clone(), entry.item_name.clone()), prefixed);
214 }
215 }
216 }
217
218 ChoiceTypeMap { types, name_map }
219}
220
221fn collect_choice_entries(cmd: &SpecCommand, entries: &mut Vec<ChoiceEntry>) {
222 if cmd.hide {
223 return;
224 }
225
226 let cmd_prefix = if cmd.name.is_empty() {
227 String::new()
228 } else {
229 AsPascalCase(&cmd.name).to_string()
230 };
231 let cmd_name = cmd.name.clone();
232
233 for arg in &cmd.args {
234 if arg.hide {
235 continue;
236 }
237 if let Some(choices) = &arg.choices {
238 let base_name = format!("{}Choice", AsPascalCase(&arg.name));
239 entries.push(ChoiceEntry {
240 base_name,
241 item_name: arg.name.clone(),
242 cmd_name: cmd_name.clone(),
243 cmd_prefix: cmd_prefix.clone(),
244 choices: choices.choices.clone(),
245 });
246 }
247 }
248
249 for flag in &cmd.flags {
250 if flag.hide {
251 continue;
252 }
253 if let Some(arg) = &flag.arg {
254 if let Some(choices) = &arg.choices {
255 let base_name = format!("{}Choice", AsPascalCase(&flag.name));
256 entries.push(ChoiceEntry {
257 base_name,
258 item_name: flag.name.clone(),
259 cmd_name: cmd_name.clone(),
260 cmd_prefix: cmd_prefix.clone(),
261 choices: choices.choices.clone(),
262 });
263 }
264 }
265 }
266
267 for subcmd in cmd.subcommands.values() {
268 collect_choice_entries(subcmd, entries);
269 }
270}
271
272pub(crate) fn collect_type_imports(
274 cmd: &SpecCommand,
275 package_name: &str,
276 choice_types: &ChoiceTypeMap,
277) -> Vec<String> {
278 let mut imports = Vec::new();
279 collect_type_imports_recursive(cmd, package_name, choice_types, &mut imports);
280 imports.sort();
281 imports.dedup();
282 imports
283}
284
285fn collect_type_imports_recursive(
286 cmd: &SpecCommand,
287 package_name: &str,
288 choice_types: &ChoiceTypeMap,
289 imports: &mut Vec<String>,
290) {
291 if cmd.hide {
292 return;
293 }
294
295 let name = command_type_name(cmd, package_name);
296 let has_args = cmd.args.iter().any(|a| !a.hide);
297 let has_flags = cmd.flags.iter().any(|f| !f.hide);
298
299 if has_args {
300 imports.push(format!("{name}Args"));
301 }
302 if has_flags {
303 imports.push(format!("{name}Flags"));
304 }
305
306 for arg in &cmd.args {
307 if !arg.hide && arg.choices.is_some() {
308 if let Some(type_name) = choice_types.lookup(&cmd.name, &arg.name) {
309 imports.push(type_name.to_string());
310 }
311 }
312 }
313 for flag in &cmd.flags {
314 if !flag.hide {
315 if let Some(arg) = &flag.arg {
316 if arg.choices.is_some() {
317 if let Some(type_name) = choice_types.lookup(&cmd.name, &flag.name) {
318 imports.push(type_name.to_string());
319 }
320 }
321 }
322 }
323 }
324
325 for subcmd in cmd.subcommands.values() {
326 collect_type_imports_recursive(subcmd, package_name, choice_types, imports);
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 #[test]
335 fn test_code_writer_display() {
336 let mut w = CodeWriter::with_indent(" ");
337 w.line("hello");
338 w.line("world");
339 let displayed = format!("{w}");
340 assert!(displayed.contains("hello"));
341 assert!(displayed.contains("world"));
342 }
343
344 #[test]
345 fn test_command_type_name_empty() {
346 let cmd = SpecCommand::default();
347 assert!(cmd.name.is_empty());
348 let result = command_type_name(&cmd, "mypackage");
349 assert_eq!(result, "Mypackage");
350 }
351
352 #[test]
353 fn test_generated_header_with_source() {
354 let result = generated_header("//", &Some("test.kdl".to_string()));
355 assert!(result.contains("test.kdl"));
356 }
357
358 #[test]
359 fn test_generated_header_without_source() {
360 let result = generated_header("//", &None);
361 assert!(!result.contains("test.kdl"));
362 assert!(result.contains("@generated"));
363 }
364
365 #[test]
368 fn test_hidden_command_with_choices() {
369 let spec: crate::Spec = r##"
370 bin "app"
371 cmd "visible" help="Visible" {
372 arg "env" help="Environment" {
373 choices "dev" "prod"
374 }
375 }
376 cmd "hidden" hide=#true help="Hidden" {
377 arg "mode" help="Mode" {
378 choices "fast" "slow"
379 }
380 flag "--level <n>" help="Level" {
381 choices "1" "2" "3"
382 }
383 }
384 "##
385 .parse()
386 .unwrap();
387 let choice_types = collect_choice_types(&spec.cmd);
388 assert!(choice_types.lookup("hidden", "mode").is_none());
390 assert!(choice_types.lookup("hidden", "level").is_none());
391 assert!(choice_types.lookup("visible", "env").is_some());
393 }
394
395 #[test]
397 fn test_hidden_arg_flag_with_choices() {
398 let spec: crate::Spec = r##"
399 bin "app"
400 arg "visible_choice" help="Visible" {
401 choices "a" "b"
402 }
403 arg "hidden_choice" hide=#true help="Hidden" {
404 choices "x" "y"
405 }
406 flag "--visible-flag <val>" help="Visible" {
407 choices "m" "n"
408 }
409 flag "--hidden-flag <val>" hide=#true help="Hidden" {
410 choices "p" "q"
411 }
412 "##
413 .parse()
414 .unwrap();
415 let choice_types = collect_choice_types(&spec.cmd);
416 assert!(choice_types.lookup("app", "visible_choice").is_some());
417 assert!(choice_types.lookup("app", "hidden_choice").is_none());
418 assert!(choice_types.lookup("app", "visible-flag").is_some());
419 assert!(choice_types.lookup("app", "hidden-flag").is_none());
420 }
421
422 #[test]
424 fn test_flag_arg_choices_import() {
425 let spec: crate::Spec = r##"
426 bin "app"
427 flag "--shell <shell>" help="Shell type" {
428 choices "bash" "zsh" "fish"
429 }
430 "##
431 .parse()
432 .unwrap();
433 let choice_types = collect_choice_types(&spec.cmd);
434 let mut imports = Vec::new();
435 collect_type_imports_recursive(&spec.cmd, "app", &choice_types, &mut imports);
436 assert!(imports.iter().any(|i| i.contains("Choice")));
437 }
438}