Skip to main content

schemaui_cli/
cli.rs

1use std::path::PathBuf;
2
3#[cfg(feature = "web")]
4use std::net::IpAddr;
5
6#[cfg(feature = "completion")]
7use clap::ValueEnum;
8use clap::{ArgAction, Args, CommandFactory, Parser, Subcommand, value_parser};
9
10#[derive(Parser, Debug, Clone, Default, PartialEq, Eq)]
11#[command(
12    name = "schemaui",
13    about = "Render JSON Schemas as interactive TUIs or Web UIs",
14    version,
15    propagate_version = true,
16    disable_help_subcommand = true,
17    subcommand_precedence_over_arg = true
18)]
19pub struct Cli {
20    #[command(flatten)]
21    pub common: CommonArgs,
22    #[command(subcommand)]
23    pub command: Option<Commands>,
24}
25
26#[derive(Subcommand, Debug, Clone, PartialEq, Eq)]
27pub enum Commands {
28    #[cfg(feature = "completion")]
29    #[command(about = "Generate shell completion scripts for the schemaui CLI")]
30    Completion(CompletionCommand),
31    #[cfg(feature = "tui")]
32    #[command(about = "Launch the interactive terminal UI")]
33    Tui(TuiCommand),
34    #[cfg(feature = "web")]
35    #[command(about = "Launch the interactive web UI instead of the terminal UI")]
36    Web(WebCommand),
37    #[cfg(feature = "web")]
38    #[command(about = "Precompute Web session snapshots instead of launching the UI")]
39    WebSnapshot(WebSnapshotCommand),
40    #[cfg(feature = "tui")]
41    #[command(
42        about = "Precompute TUI FormSchema/LayoutNavModel modules instead of launching the UI"
43    )]
44    TuiSnapshot(TuiSnapshotCommand),
45}
46
47#[cfg(feature = "tui")]
48#[derive(Args, Debug, Clone, Default, PartialEq, Eq)]
49pub struct TuiCommand {
50    #[command(flatten)]
51    pub common: CommonArgs,
52}
53
54#[cfg(feature = "completion")]
55#[derive(Args, Debug, Clone, Copy, PartialEq, Eq)]
56pub struct CompletionCommand {
57    #[arg(help = "target shell: bash, zsh, fish, or powershell")]
58    pub shell: CompletionShell,
59}
60
61#[cfg(feature = "completion")]
62#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
63pub enum CompletionShell {
64    #[value(name = "bash")]
65    Bash,
66    #[value(name = "zsh")]
67    Zsh,
68    #[value(name = "fish")]
69    Fish,
70    #[value(name = "powershell")]
71    PowerShell,
72}
73
74#[cfg(feature = "web")]
75#[derive(Args, Debug, Clone, PartialEq, Eq)]
76pub struct WebCommand {
77    #[command(flatten)]
78    pub common: CommonArgs,
79    #[arg(
80        short = 'l',
81        long = "host",
82        visible_aliases = ["bind", "listen"],
83        value_name = "IP",
84        help = "bind address for the temporary HTTP server",
85        value_parser = value_parser!(IpAddr),
86        default_value = "127.0.0.1"
87    )]
88    pub host: IpAddr,
89    #[arg(
90        short = 'p',
91        long = "port",
92        value_name = "PORT",
93        help = "bind port for the temporary HTTP server (0 picks a random free port)",
94        value_parser = value_parser!(u16),
95        default_value_t = 0
96    )]
97    pub port: u16,
98}
99
100#[cfg(feature = "web")]
101#[derive(Args, Debug, Clone, PartialEq, Eq)]
102pub struct WebSnapshotCommand {
103    #[command(flatten)]
104    pub common: CommonArgs,
105    #[arg(
106        long = "out-dir",
107        value_name = "DIR",
108        help = "output directory for generated Web snapshots (JSON + TS)",
109        value_parser = value_parser!(PathBuf),
110        default_value = "web_snapshots"
111    )]
112    pub out_dir: PathBuf,
113    #[arg(
114        long = "ts-export",
115        value_name = "NAME",
116        help = "name of the exported constant in the generated TS module",
117        default_value = "SessionSnapshot"
118    )]
119    pub ts_export: String,
120}
121
122#[cfg(feature = "tui")]
123#[derive(Args, Debug, Clone, PartialEq, Eq)]
124pub struct TuiSnapshotCommand {
125    #[command(flatten)]
126    pub common: CommonArgs,
127    #[arg(
128        long = "out-dir",
129        value_name = "DIR",
130        help = "output directory for generated TUI artifact modules (Rust source)",
131        value_parser = value_parser!(PathBuf),
132        default_value = "tui_artifacts"
133    )]
134    pub out_dir: PathBuf,
135    #[arg(
136        long = "tui-fn",
137        value_name = "NAME",
138        help = "name of the generated TuiArtifacts constructor function",
139        default_value = "tui_artifacts"
140    )]
141    pub tui_fn: String,
142    #[arg(
143        long = "form-fn",
144        value_name = "NAME",
145        help = "name of the generated FormSchema constructor function",
146        default_value = "tui_form_schema"
147    )]
148    pub form_fn: String,
149    #[arg(
150        long = "layout-fn",
151        value_name = "NAME",
152        help = "name of the generated LayoutNavModel constructor function",
153        default_value = "tui_layout_nav"
154    )]
155    pub layout_fn: String,
156}
157
158#[derive(Args, Debug, Clone, Default, PartialEq, Eq)]
159pub struct CommonArgs {
160    #[arg(
161        short = 's',
162        long = "schema",
163        help = "schema spec: local path, file/HTTP URL, inline payload, or \"-\" for stdin",
164        allow_hyphen_values = true
165    )]
166    pub schema: Option<String>,
167    #[arg(
168        short = 'c',
169        long = "config",
170        visible_alias = "data",
171        help = "config spec: local path, file/HTTP URL, inline payload, or \"-\" for stdin",
172        allow_hyphen_values = true
173    )]
174    pub config: Option<String>,
175    #[arg(
176        long = "title",
177        help = "title shown at the top of the UI",
178        allow_hyphen_values = true
179    )]
180    pub title: Option<String>,
181    #[arg(
182        long = "description",
183        help = "description shown under the title in the active UI",
184        allow_hyphen_values = true
185    )]
186    pub description: Option<String>,
187    #[arg(
188        short = 'o',
189        long = "output",
190        value_name = "DEST",
191        help = "output destinations (\"-\" writes to stdout). Repeat the flag to add more",
192        action = ArgAction::Append,
193        num_args = 1..,
194        allow_hyphen_values = true
195    )]
196    pub outputs: Vec<String>,
197    #[arg(
198        long = "temp-file",
199        value_name = "PATH",
200        help = "write to PATH when no destinations are set (stdout remains the default)",
201        value_parser = value_parser!(PathBuf)
202    )]
203    pub temp_file: Option<PathBuf>,
204    #[arg(
205        long = "no-temp-file",
206        help = "compatibility no-op: stdout is already the default when no destinations are set",
207        action = ArgAction::SetTrue
208    )]
209    pub no_temp_file: bool,
210    #[arg(
211        long = "no-pretty",
212        help = "emit compact JSON/TOML rather than pretty formatting",
213        action = ArgAction::SetTrue
214    )]
215    pub no_pretty: bool,
216    #[arg(
217        short = 'f',
218        long = "force",
219        visible_short_alias = 'y',
220        visible_alias = "yes",
221        help = "overwrite output files even if they already exist",
222        action = ArgAction::SetTrue
223    )]
224    pub force: bool,
225}
226
227impl CommonArgs {
228    pub fn merged_with(&self, local: &Self) -> Self {
229        let mut outputs = self.outputs.clone();
230        outputs.extend(local.outputs.clone());
231
232        Self {
233            schema: local.schema.clone().or_else(|| self.schema.clone()),
234            config: local.config.clone().or_else(|| self.config.clone()),
235            title: local.title.clone().or_else(|| self.title.clone()),
236            description: local
237                .description
238                .clone()
239                .or_else(|| self.description.clone()),
240            outputs,
241            temp_file: local.temp_file.clone().or_else(|| self.temp_file.clone()),
242            no_temp_file: self.no_temp_file || local.no_temp_file,
243            no_pretty: self.no_pretty || local.no_pretty,
244            force: self.force || local.force,
245        }
246    }
247}
248
249#[derive(Debug, Clone, PartialEq, Eq)]
250pub struct CliParseExit {
251    pub output: String,
252    pub status: Result<(), ()>,
253}
254
255impl CliParseExit {
256    fn success(output: String) -> Self {
257        Self {
258            output,
259            status: Ok(()),
260        }
261    }
262
263    fn error(output: String) -> Self {
264        Self {
265            output,
266            status: Err(()),
267        }
268    }
269}
270
271impl Cli {
272    pub fn parse() -> Self {
273        Self::from_env_or_exit()
274    }
275
276    pub fn from_env_or_exit() -> Self {
277        match Self::try_parse_from(std::env::args()) {
278            Ok(cli) => cli,
279            Err(exit) => {
280                if exit.status.is_ok() {
281                    print!("{}", exit.output);
282                    std::process::exit(0);
283                }
284                eprint!("{}", exit.output);
285                std::process::exit(1);
286            }
287        }
288    }
289
290    pub fn parse_from<I, T>(args: I) -> Self
291    where
292        I: IntoIterator<Item = T>,
293        T: Into<String>,
294    {
295        Self::try_parse_from(args).unwrap_or_else(|exit| {
296            panic!("failed to parse args: {}", exit.output);
297        })
298    }
299
300    pub fn try_parse_from<I, T>(args: I) -> Result<Self, CliParseExit>
301    where
302        I: IntoIterator<Item = T>,
303        T: Into<String>,
304    {
305        let argv = args.into_iter().map(Into::into).collect::<Vec<_>>();
306        <Self as Parser>::try_parse_from(argv).map_err(clap_error_to_exit)
307    }
308}
309
310pub fn command_info() -> clap::Command {
311    <Cli as CommandFactory>::command()
312}
313
314fn clap_error_to_exit(err: clap::Error) -> CliParseExit {
315    let output = err.to_string();
316    match err.kind() {
317        clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion => {
318            CliParseExit::success(output)
319        }
320        _ => CliParseExit::error(output),
321    }
322}