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}