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 backslashes and double quotes for Python string literals.
56pub(crate) fn escape_py_string(s: &str) -> String {
57    s.replace('\\', r"\\").replace('"', r#"\""#)
58}
59
60/// Escape backslashes and double quotes for TypeScript string literals.
61pub(crate) fn escape_ts_string(s: &str) -> String {
62    s.replace('\\', r"\\").replace('"', r#"\""#)
63}
64
65/// A simple code writer with indentation management.
66pub(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    /// Create a CodeWriter with custom indent string (e.g. "    " for Python).
82    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
128/// Returns the PascalCase type name for a command: the command name, or the package name for root.
129pub(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
137// ---------------------------------------------------------------------------
138// Choice type collection with collision detection
139// ---------------------------------------------------------------------------
140
141/// Maps choice type definitions and provides collision-aware type name lookup.
142///
143/// When two commands have the same arg/flag name with different choices,
144/// the type name is prefixed with the command's PascalCase name to avoid collision.
145pub(crate) struct ChoiceTypeMap {
146    /// Resolved type name -> choice values
147    pub types: IndexMap<String, Vec<String>>,
148    /// (cmd_name, item_name) -> resolved type name
149    name_map: IndexMap<(String, String), String>,
150}
151
152impl ChoiceTypeMap {
153    /// Look up the resolved type name for a choice arg/flag.
154    /// `cmd_name` is the command's name (empty string for root).
155    /// `item_name` is the arg or flag name.
156    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    /// Iterate over type definitions (name, choices).
163    pub fn iter(&self) -> indexmap::map::Iter<'_, String, Vec<String>> {
164        self.types.iter()
165    }
166
167    /// Check if there are no choice types.
168    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
181/// Collects all unique choice types across a command tree.
182/// When two commands have the same arg/flag name with different choices,
183/// the type name is prefixed with the command's PascalCase name to avoid collision.
184pub(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    // Group by base type name, check for choice differences
189    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
272/// Collects type names that need to be imported from the types module.
273pub(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    /// Hidden command with choices — covers collect_choice_entries skip paths
366    /// and collect_type_imports_recursive cmd.hide path.
367    #[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        // hidden command's choices should not be collected
389        assert!(choice_types.lookup("hidden", "mode").is_none());
390        assert!(choice_types.lookup("hidden", "level").is_none());
391        // visible command's choices should be collected
392        assert!(choice_types.lookup("visible", "env").is_some());
393    }
394
395    /// Hidden arg/flag with choices — covers skip paths in collect_choice_entries.
396    #[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    /// Flag with arg choices — covers flag.arg choices import path.
423    #[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}