Skip to main content

schemaui_cli/
cli.rs

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