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