Skip to main content

usage/sdk/
mod.rs

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
45/// Escape JSDoc-terminating sequences in comment text.
46pub(crate) fn escape_jsdoc(s: &str) -> String {
47    s.replace("*/", r"*\/")
48}
49
50/// Escape triple-quote sequences and backslashes in Python docstrings.
51pub(crate) fn escape_py_docstring(s: &str) -> String {
52    s.replace('\\', r"\\").replace(r#"""""#, r#"\"\"\""#)
53}
54
55/// Escape a string for a double-quoted literal.
56///
57/// Backslashes, quotes, and control characters — the last of these because neither language
58/// can carry one literally inside a quoted string, so a value with a newline in it wrote a
59/// module that fails to import rather than one that says something wrong. Help text and
60/// config defaults both really do contain them.
61///
62/// Python and TypeScript spell all of these the same way, so one function serves both.
63fn escape_string_literal(s: &str) -> String {
64    let mut out = String::with_capacity(s.len());
65    for c in s.chars() {
66        match c {
67            '\\' => out.push_str(r"\\"),
68            '"' => out.push_str(r#"\""#),
69            '\n' => out.push_str(r"\n"),
70            '\r' => out.push_str(r"\r"),
71            '\t' => out.push_str(r"\t"),
72            c if c.is_control() => out.push_str(&format!("\\x{:02x}", c as u32)),
73            c => out.push(c),
74        }
75    }
76    out
77}
78
79/// Escape a string for a Python literal.
80pub(crate) fn escape_py_string(s: &str) -> String {
81    escape_string_literal(s)
82}
83
84/// Escape a string for a TypeScript literal.
85pub(crate) fn escape_ts_string(s: &str) -> String {
86    escape_string_literal(s)
87}
88
89/// A simple code writer with indentation management.
90pub(crate) struct CodeWriter {
91    buf: String,
92    indent: usize,
93    indent_str: &'static str,
94}
95
96impl CodeWriter {
97    pub fn new() -> Self {
98        Self {
99            buf: String::new(),
100            indent: 0,
101            indent_str: "  ",
102        }
103    }
104
105    /// Create a CodeWriter with custom indent string (e.g. "    " for Python).
106    pub fn with_indent(indent_str: &'static str) -> Self {
107        Self {
108            buf: String::new(),
109            indent: 0,
110            indent_str,
111        }
112    }
113
114    pub fn line(&mut self, s: &str) {
115        if !s.is_empty() {
116            for _ in 0..self.indent {
117                self.buf.push_str(self.indent_str);
118            }
119        }
120        self.buf.push_str(s);
121        self.buf.push('\n');
122    }
123
124    pub fn indent(&mut self) {
125        self.indent += 1;
126    }
127
128    pub fn dedent(&mut self) {
129        self.indent = self.indent.saturating_sub(1);
130    }
131
132    pub fn finish(self) -> String {
133        self.buf
134    }
135}
136
137impl fmt::Display for CodeWriter {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        write!(f, "{}", self.buf)
140    }
141}
142
143pub(crate) fn generated_header(comment_prefix: &str, source: &Option<String>) -> String {
144    match source {
145        Some(s) => {
146            format!("{comment_prefix} @generated by usage-cli from {s}. Do not edit manually.")
147        }
148        None => format!("{comment_prefix} @generated by usage-cli. Do not edit manually."),
149    }
150}
151
152/// Returns the PascalCase type name for a command: the command name, or the package name for root.
153pub(crate) fn command_type_name(cmd: &SpecCommand, package_name: &str) -> String {
154    if cmd.name.is_empty() {
155        AsPascalCase(package_name).to_string()
156    } else {
157        AsPascalCase(&cmd.name).to_string()
158    }
159}
160
161// ---------------------------------------------------------------------------
162// Choice type collection with collision detection
163// ---------------------------------------------------------------------------
164
165/// Maps choice type definitions and provides collision-aware type name lookup.
166///
167/// When two commands have the same arg/flag name with different choices,
168/// the type name is prefixed with the command's PascalCase name to avoid collision.
169pub(crate) struct ChoiceTypeMap {
170    /// Resolved type name -> choice values
171    pub types: IndexMap<String, Vec<String>>,
172    /// (cmd_name, item_name) -> resolved type name
173    name_map: IndexMap<(String, String), String>,
174}
175
176impl ChoiceTypeMap {
177    /// Look up the resolved type name for a choice arg/flag.
178    /// `cmd_name` is the command's name (empty string for root).
179    /// `item_name` is the arg or flag name.
180    pub fn lookup(&self, cmd_name: &str, item_name: &str) -> Option<&str> {
181        self.name_map
182            .get(&(cmd_name.to_string(), item_name.to_string()))
183            .map(|s| s.as_str())
184    }
185
186    /// Iterate over type definitions (name, choices).
187    pub fn iter(&self) -> indexmap::map::Iter<'_, String, Vec<String>> {
188        self.types.iter()
189    }
190
191    /// Check if there are no choice types.
192    pub fn is_empty(&self) -> bool {
193        self.types.is_empty()
194    }
195}
196
197struct ChoiceEntry {
198    base_name: String,
199    item_name: String,
200    cmd_name: String,
201    cmd_prefix: String,
202    choices: Vec<String>,
203}
204
205/// Collects all unique choice types across a command tree.
206/// When two commands have the same arg/flag name with different choices,
207/// the type name is prefixed with the command's PascalCase name to avoid collision.
208pub(crate) fn collect_choice_types(cmd: &SpecCommand) -> ChoiceTypeMap {
209    let mut all_entries: Vec<ChoiceEntry> = Vec::new();
210    collect_choice_entries(cmd, &mut all_entries);
211
212    // Group by base type name, check for choice differences
213    let mut base_groups: IndexMap<String, Vec<&ChoiceEntry>> = IndexMap::new();
214    for entry in &all_entries {
215        base_groups
216            .entry(entry.base_name.clone())
217            .or_default()
218            .push(entry);
219    }
220
221    let mut types = IndexMap::new();
222    let mut name_map = IndexMap::new();
223    for (base_name, entries) in &base_groups {
224        let all_same = entries.windows(2).all(|w| w[0].choices == w[1].choices);
225        if all_same {
226            types.insert(base_name.clone(), entries[0].choices.clone());
227            for entry in entries {
228                name_map.insert(
229                    (entry.cmd_name.clone(), entry.item_name.clone()),
230                    base_name.clone(),
231                );
232            }
233        } else {
234            for entry in entries {
235                let prefixed = format!("{}{}", entry.cmd_prefix, base_name);
236                types.insert(prefixed.clone(), entry.choices.clone());
237                name_map.insert((entry.cmd_name.clone(), entry.item_name.clone()), prefixed);
238            }
239        }
240    }
241
242    ChoiceTypeMap { types, name_map }
243}
244
245fn collect_choice_entries(cmd: &SpecCommand, entries: &mut Vec<ChoiceEntry>) {
246    if cmd.hide {
247        return;
248    }
249
250    let cmd_prefix = if cmd.name.is_empty() {
251        String::new()
252    } else {
253        AsPascalCase(&cmd.name).to_string()
254    };
255    let cmd_name = cmd.name.clone();
256
257    for arg in &cmd.args {
258        if arg.hide {
259            continue;
260        }
261        if let Some(choices) = &arg.choices {
262            let base_name = format!("{}Choice", AsPascalCase(&arg.name));
263            entries.push(ChoiceEntry {
264                base_name,
265                item_name: arg.name.clone(),
266                cmd_name: cmd_name.clone(),
267                cmd_prefix: cmd_prefix.clone(),
268                choices: choices.choices.clone(),
269            });
270        }
271    }
272
273    for flag in &cmd.flags {
274        if flag.hide {
275            continue;
276        }
277        if let Some(arg) = &flag.arg {
278            if let Some(choices) = &arg.choices {
279                let base_name = format!("{}Choice", AsPascalCase(&flag.name));
280                entries.push(ChoiceEntry {
281                    base_name,
282                    item_name: flag.name.clone(),
283                    cmd_name: cmd_name.clone(),
284                    cmd_prefix: cmd_prefix.clone(),
285                    choices: choices.choices.clone(),
286                });
287            }
288        }
289    }
290
291    for subcmd in cmd.subcommands.values() {
292        collect_choice_entries(subcmd, entries);
293    }
294}
295
296/// Collects type names that need to be imported from the types module.
297pub(crate) fn collect_type_imports(
298    cmd: &SpecCommand,
299    package_name: &str,
300    choice_types: &ChoiceTypeMap,
301) -> Vec<String> {
302    let mut imports = Vec::new();
303    collect_type_imports_recursive(cmd, package_name, choice_types, &mut imports);
304    imports.sort();
305    imports.dedup();
306    imports
307}
308
309fn collect_type_imports_recursive(
310    cmd: &SpecCommand,
311    package_name: &str,
312    choice_types: &ChoiceTypeMap,
313    imports: &mut Vec<String>,
314) {
315    if cmd.hide {
316        return;
317    }
318
319    let name = command_type_name(cmd, package_name);
320    let has_args = cmd.args.iter().any(|a| !a.hide);
321    let has_flags = cmd.flags.iter().any(|f| !f.hide);
322
323    if has_args {
324        imports.push(format!("{name}Args"));
325    }
326    if has_flags {
327        imports.push(format!("{name}Flags"));
328    }
329
330    for arg in &cmd.args {
331        if !arg.hide && arg.choices.is_some() {
332            if let Some(type_name) = choice_types.lookup(&cmd.name, &arg.name) {
333                imports.push(type_name.to_string());
334            }
335        }
336    }
337    for flag in &cmd.flags {
338        if !flag.hide {
339            if let Some(arg) = &flag.arg {
340                if arg.choices.is_some() {
341                    if let Some(type_name) = choice_types.lookup(&cmd.name, &flag.name) {
342                        imports.push(type_name.to_string());
343                    }
344                }
345            }
346        }
347    }
348
349    for subcmd in cmd.subcommands.values() {
350        collect_type_imports_recursive(subcmd, package_name, choice_types, imports);
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn test_code_writer_display() {
360        let mut w = CodeWriter::with_indent("    ");
361        w.line("hello");
362        w.line("world");
363        let displayed = format!("{w}");
364        assert!(displayed.contains("hello"));
365        assert!(displayed.contains("world"));
366    }
367
368    #[test]
369    fn test_command_type_name_empty() {
370        let cmd = SpecCommand::default();
371        assert!(cmd.name.is_empty());
372        let result = command_type_name(&cmd, "mypackage");
373        assert_eq!(result, "Mypackage");
374    }
375
376    #[test]
377    fn test_generated_header_with_source() {
378        let result = generated_header("//", &Some("test.kdl".to_string()));
379        assert!(result.contains("test.kdl"));
380    }
381
382    #[test]
383    fn test_generated_header_without_source() {
384        let result = generated_header("//", &None);
385        assert!(!result.contains("test.kdl"));
386        assert!(result.contains("@generated"));
387    }
388
389    /// Hidden command with choices — covers collect_choice_entries skip paths
390    /// and collect_type_imports_recursive cmd.hide path.
391    #[test]
392    fn test_hidden_command_with_choices() {
393        let spec: crate::Spec = r##"
394            bin "app"
395            cmd "visible" help="Visible" {
396                arg "env" help="Environment" {
397                    choices "dev" "prod"
398                }
399            }
400            cmd "hidden" hide=#true help="Hidden" {
401                arg "mode" help="Mode" {
402                    choices "fast" "slow"
403                }
404                flag "--level <n>" help="Level" {
405                    choices "1" "2" "3"
406                }
407            }
408        "##
409        .parse()
410        .unwrap();
411        let choice_types = collect_choice_types(&spec.cmd);
412        // hidden command's choices should not be collected
413        assert!(choice_types.lookup("hidden", "mode").is_none());
414        assert!(choice_types.lookup("hidden", "level").is_none());
415        // visible command's choices should be collected
416        assert!(choice_types.lookup("visible", "env").is_some());
417    }
418
419    /// Hidden arg/flag with choices — covers skip paths in collect_choice_entries.
420    #[test]
421    fn test_hidden_arg_flag_with_choices() {
422        let spec: crate::Spec = r##"
423            bin "app"
424            arg "visible_choice" help="Visible" {
425                choices "a" "b"
426            }
427            arg "hidden_choice" hide=#true help="Hidden" {
428                choices "x" "y"
429            }
430            flag "--visible-flag <val>" help="Visible" {
431                choices "m" "n"
432            }
433            flag "--hidden-flag <val>" hide=#true help="Hidden" {
434                choices "p" "q"
435            }
436        "##
437        .parse()
438        .unwrap();
439        let choice_types = collect_choice_types(&spec.cmd);
440        assert!(choice_types.lookup("app", "visible_choice").is_some());
441        assert!(choice_types.lookup("app", "hidden_choice").is_none());
442        assert!(choice_types.lookup("app", "visible-flag").is_some());
443        assert!(choice_types.lookup("app", "hidden-flag").is_none());
444    }
445
446    /// Flag with arg choices — covers flag.arg choices import path.
447    #[test]
448    fn test_flag_arg_choices_import() {
449        let spec: crate::Spec = r##"
450            bin "app"
451            flag "--shell <shell>" help="Shell type" {
452                choices "bash" "zsh" "fish"
453            }
454        "##
455        .parse()
456        .unwrap();
457        let choice_types = collect_choice_types(&spec.cmd);
458        let mut imports = Vec::new();
459        collect_type_imports_recursive(&spec.cmd, "app", &choice_types, &mut imports);
460        assert!(imports.iter().any(|i| i.contains("Choice")));
461    }
462}