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}