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    #[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}