Skip to main content

schemaui_cli/
cli.rs

1use std::path::PathBuf;
2
3use argh::{ArgsInfo, FromArgValue, FromArgs};
4
5#[cfg(feature = "web")]
6use std::net::IpAddr;
7
8#[derive(Debug, Clone, Default, PartialEq, Eq)]
9pub struct Cli {
10    pub common: CommonArgs,
11    pub command: Option<Commands>,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum Commands {
16    Completion(CompletionCommand),
17    Tui(TuiCommand),
18    #[cfg(feature = "web")]
19    Web(WebCommand),
20    #[cfg(feature = "web")]
21    WebSnapshot(WebSnapshotCommand),
22    TuiSnapshot(TuiSnapshotCommand),
23}
24
25#[derive(Debug, Clone, Default, PartialEq, Eq)]
26pub struct TuiCommand {
27    pub common: CommonArgs,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct CompletionCommand {
32    pub shell: CompletionShell,
33}
34
35#[derive(FromArgValue, Debug, Clone, Copy, PartialEq, Eq)]
36pub enum CompletionShell {
37    Bash,
38    Zsh,
39    Fish,
40    Nushell,
41}
42
43#[cfg(feature = "web")]
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct WebCommand {
46    pub common: CommonArgs,
47    pub host: IpAddr,
48    pub port: u16,
49}
50
51#[cfg(feature = "web")]
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct WebSnapshotCommand {
54    pub common: CommonArgs,
55    pub out_dir: PathBuf,
56    pub ts_export: String,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct TuiSnapshotCommand {
61    pub common: CommonArgs,
62    pub out_dir: PathBuf,
63    pub tui_fn: String,
64    pub form_fn: String,
65    pub layout_fn: String,
66}
67
68#[derive(Debug, Clone, Default, PartialEq, Eq)]
69pub struct CommonArgs {
70    pub schema: Option<String>,
71    pub config: Option<String>,
72    pub title: Option<String>,
73    pub outputs: Vec<String>,
74    pub temp_file: Option<PathBuf>,
75    pub no_temp_file: bool,
76    pub no_pretty: bool,
77    pub force: bool,
78}
79
80impl CommonArgs {
81    pub fn merged_with(&self, local: &Self) -> Self {
82        let mut outputs = self.outputs.clone();
83        outputs.extend(local.outputs.clone());
84
85        Self {
86            schema: local.schema.clone().or_else(|| self.schema.clone()),
87            config: local.config.clone().or_else(|| self.config.clone()),
88            title: local.title.clone().or_else(|| self.title.clone()),
89            outputs,
90            temp_file: local.temp_file.clone().or_else(|| self.temp_file.clone()),
91            no_temp_file: self.no_temp_file || local.no_temp_file,
92            no_pretty: self.no_pretty || local.no_pretty,
93            force: self.force || local.force,
94        }
95    }
96}
97
98impl Cli {
99    pub fn parse() -> Self {
100        Self::from_env_or_exit()
101    }
102
103    pub fn from_env_or_exit() -> Self {
104        match Self::try_parse_from(std::env::args()) {
105            Ok(cli) => cli,
106            Err(exit) => {
107                if exit.status.is_ok() {
108                    print!("{}", exit.output);
109                    std::process::exit(0);
110                }
111                eprint!("{}", exit.output);
112                std::process::exit(1);
113            }
114        }
115    }
116
117    pub fn parse_from<I, T>(args: I) -> Self
118    where
119        I: IntoIterator<Item = T>,
120        T: Into<String>,
121    {
122        Self::try_parse_from(args).unwrap_or_else(|exit| {
123            panic!("failed to parse args: {}", exit.output);
124        })
125    }
126
127    pub fn try_parse_from<I, T>(args: I) -> Result<Self, argh::EarlyExit>
128    where
129        I: IntoIterator<Item = T>,
130        T: Into<String>,
131    {
132        let raw = args.into_iter().map(Into::into).collect::<Vec<_>>();
133        let program = raw
134            .first()
135            .cloned()
136            .unwrap_or_else(|| "schemaui".to_string());
137
138        let normalized = normalize_args(&raw[1..]);
139        let scan = scan_for_command(&normalized);
140        let mut parse_args = normalized.clone();
141        let injected_default_tui = matches!(scan, CommandScan::None);
142        if injected_default_tui {
143            parse_args.push("tui".to_string());
144        }
145        let parse_args = expand_output_values(&parse_args);
146        let parse_refs = parse_args.iter().map(String::as_str).collect::<Vec<_>>();
147        let parsed = ArghCli::from_args(&[program.as_str()], &parse_refs)?;
148        Ok(Self::from_argh(parsed, injected_default_tui))
149    }
150
151    fn from_argh(parsed: ArghCli, injected_default_tui: bool) -> Self {
152        let common = common_args_from_root(&parsed);
153        match parsed.command {
154            ArghCommands::Tui(_command) if injected_default_tui => Self {
155                common,
156                command: None,
157            },
158            ArghCommands::Completion(command) => Self {
159                common,
160                command: Some(Commands::Completion(CompletionCommand {
161                    shell: command.shell,
162                })),
163            },
164            ArghCommands::Tui(command) => Self {
165                common,
166                command: Some(Commands::Tui(TuiCommand {
167                    common: common_args_from_tui(command),
168                })),
169            },
170            #[cfg(feature = "web")]
171            ArghCommands::Web(command) => Self {
172                common,
173                command: Some(Commands::Web(WebCommand {
174                    common: common_args_from_web(&command),
175                    host: command.host,
176                    port: command.port,
177                })),
178            },
179            #[cfg(feature = "web")]
180            ArghCommands::WebSnapshot(command) => Self {
181                common,
182                command: Some(Commands::WebSnapshot(WebSnapshotCommand {
183                    common: common_args_from_web_snapshot(&command),
184                    out_dir: command.out_dir,
185                    ts_export: command.ts_export,
186                })),
187            },
188            ArghCommands::TuiSnapshot(command) => Self {
189                common,
190                command: Some(Commands::TuiSnapshot(TuiSnapshotCommand {
191                    common: common_args_from_tui_snapshot(&command),
192                    out_dir: command.out_dir,
193                    tui_fn: command.tui_fn,
194                    form_fn: command.form_fn,
195                    layout_fn: command.layout_fn,
196                })),
197            },
198        }
199    }
200}
201
202pub fn command_info() -> argh::CommandInfoWithArgs {
203    ArghCli::get_args_info()
204}
205
206fn common_args_from_root(args: &ArghCli) -> CommonArgs {
207    CommonArgs {
208        schema: args.schema.clone(),
209        config: args.config.clone(),
210        title: args.title.clone(),
211        outputs: args.outputs.clone(),
212        temp_file: args.temp_file.clone(),
213        no_temp_file: args.no_temp_file,
214        no_pretty: args.no_pretty,
215        force: args.force,
216    }
217}
218
219fn common_args_from_tui(args: ArghTuiCommand) -> CommonArgs {
220    CommonArgs {
221        schema: args.schema,
222        config: args.config,
223        title: args.title,
224        outputs: args.outputs,
225        temp_file: args.temp_file,
226        no_temp_file: args.no_temp_file,
227        no_pretty: args.no_pretty,
228        force: args.force,
229    }
230}
231
232#[cfg(feature = "web")]
233fn common_args_from_web(args: &ArghWebCommand) -> CommonArgs {
234    CommonArgs {
235        schema: args.schema.clone(),
236        config: args.config.clone(),
237        title: args.title.clone(),
238        outputs: args.outputs.clone(),
239        temp_file: args.temp_file.clone(),
240        no_temp_file: args.no_temp_file,
241        no_pretty: args.no_pretty,
242        force: args.force,
243    }
244}
245
246#[cfg(feature = "web")]
247fn common_args_from_web_snapshot(args: &ArghWebSnapshotCommand) -> CommonArgs {
248    CommonArgs {
249        schema: args.schema.clone(),
250        config: args.config.clone(),
251        title: args.title.clone(),
252        outputs: args.outputs.clone(),
253        temp_file: args.temp_file.clone(),
254        no_temp_file: args.no_temp_file,
255        no_pretty: args.no_pretty,
256        force: args.force,
257    }
258}
259
260fn common_args_from_tui_snapshot(args: &ArghTuiSnapshotCommand) -> CommonArgs {
261    CommonArgs {
262        schema: args.schema.clone(),
263        config: args.config.clone(),
264        title: args.title.clone(),
265        outputs: args.outputs.clone(),
266        temp_file: args.temp_file.clone(),
267        no_temp_file: args.no_temp_file,
268        no_pretty: args.no_pretty,
269        force: args.force,
270    }
271}
272
273#[derive(FromArgs, ArgsInfo, Debug, PartialEq)]
274#[argh(help_triggers("-h", "--help", "help"))]
275/// Render JSON Schemas as interactive TUIs or Web UIs
276struct ArghCli {
277    /// schema spec: local path, file/HTTP URL, inline payload, or "-" for stdin
278    #[argh(option, short = 's')]
279    schema: Option<String>,
280
281    /// config spec: local path, file/HTTP URL, inline payload, or "-" for stdin
282    #[argh(option, short = 'c')]
283    config: Option<String>,
284
285    /// title shown at the top of the UI
286    #[argh(option)]
287    title: Option<String>,
288
289    /// output destinations ("-" writes to stdout). Repeat the flag to add more.
290    #[argh(option, short = 'o', long = "output")]
291    outputs: Vec<String>,
292
293    /// write to PATH when no destinations are set (stdout remains the default)
294    #[argh(option)]
295    temp_file: Option<PathBuf>,
296
297    /// compatibility no-op: stdout is already the default when no destinations are set
298    #[argh(switch)]
299    no_temp_file: bool,
300
301    /// emit compact JSON/TOML rather than pretty formatting
302    #[argh(switch)]
303    no_pretty: bool,
304
305    /// overwrite output files even if they already exist
306    #[argh(switch, short = 'f')]
307    force: bool,
308
309    #[argh(subcommand)]
310    command: ArghCommands,
311}
312
313#[derive(FromArgs, ArgsInfo, Debug, PartialEq)]
314#[argh(subcommand)]
315enum ArghCommands {
316    Completion(ArghCompletionCommand),
317    Tui(ArghTuiCommand),
318    #[cfg(feature = "web")]
319    Web(ArghWebCommand),
320    #[cfg(feature = "web")]
321    WebSnapshot(ArghWebSnapshotCommand),
322    TuiSnapshot(ArghTuiSnapshotCommand),
323}
324
325#[derive(FromArgs, ArgsInfo, Debug, PartialEq)]
326/// Generate shell completion scripts for the schemaui CLI
327#[argh(subcommand, name = "completion", help_triggers("-h", "--help", "help"))]
328struct ArghCompletionCommand {
329    /// target shell: bash, zsh, fish, or nushell
330    #[argh(positional)]
331    shell: CompletionShell,
332}
333
334#[derive(FromArgs, ArgsInfo, Debug, PartialEq)]
335#[argh(subcommand, name = "tui", help_triggers("-h", "--help", "help"))]
336/// Launch the interactive terminal UI
337struct ArghTuiCommand {
338    /// schema spec: local path, file/HTTP URL, inline payload, or "-" for stdin
339    #[argh(option, short = 's')]
340    schema: Option<String>,
341
342    /// config spec: local path, file/HTTP URL, inline payload, or "-" for stdin
343    #[argh(option, short = 'c')]
344    config: Option<String>,
345
346    /// title shown at the top of the UI
347    #[argh(option)]
348    title: Option<String>,
349
350    /// output destinations ("-" writes to stdout). Repeat the flag to add more.
351    #[argh(option, short = 'o', long = "output")]
352    outputs: Vec<String>,
353
354    /// write to PATH when no destinations are set (stdout remains the default)
355    #[argh(option)]
356    temp_file: Option<PathBuf>,
357
358    /// compatibility no-op: stdout is already the default when no destinations are set
359    #[argh(switch)]
360    no_temp_file: bool,
361
362    /// emit compact JSON/TOML rather than pretty formatting
363    #[argh(switch)]
364    no_pretty: bool,
365
366    /// overwrite output files even if they already exist
367    #[argh(switch, short = 'f')]
368    force: bool,
369}
370
371#[cfg(feature = "web")]
372#[derive(FromArgs, ArgsInfo, Debug, PartialEq)]
373#[argh(subcommand, name = "web", help_triggers("-h", "--help", "help"))]
374/// Launch the interactive web UI instead of the terminal UI
375struct ArghWebCommand {
376    /// schema spec: local path, file/HTTP URL, inline payload, or "-" for stdin
377    #[argh(option, short = 's')]
378    schema: Option<String>,
379
380    /// config spec: local path, file/HTTP URL, inline payload, or "-" for stdin
381    #[argh(option, short = 'c')]
382    config: Option<String>,
383
384    /// title shown at the top of the UI
385    #[argh(option)]
386    title: Option<String>,
387
388    /// output destinations ("-" writes to stdout). Repeat the flag to add more.
389    #[argh(option, short = 'o', long = "output")]
390    outputs: Vec<String>,
391
392    /// write to PATH when no destinations are set (stdout remains the default)
393    #[argh(option)]
394    temp_file: Option<PathBuf>,
395
396    /// compatibility no-op: stdout is already the default when no destinations are set
397    #[argh(switch)]
398    no_temp_file: bool,
399
400    /// emit compact JSON/TOML rather than pretty formatting
401    #[argh(switch)]
402    no_pretty: bool,
403
404    /// overwrite output files even if they already exist
405    #[argh(switch, short = 'f')]
406    force: bool,
407
408    /// bind address for the temporary HTTP server
409    #[argh(option, short = 'l', default = "default_host()")]
410    host: IpAddr,
411
412    /// bind port for the temporary HTTP server (0 picks a random free port)
413    #[argh(option, short = 'p', default = "0")]
414    port: u16,
415}
416
417#[cfg(feature = "web")]
418#[derive(FromArgs, ArgsInfo, Debug, PartialEq)]
419#[argh(
420    subcommand,
421    name = "web-snapshot",
422    help_triggers("-h", "--help", "help")
423)]
424/// Precompute Web session snapshots instead of launching the UI
425struct ArghWebSnapshotCommand {
426    /// schema spec: local path, file/HTTP URL, inline payload, or "-" for stdin
427    #[argh(option, short = 's')]
428    schema: Option<String>,
429
430    /// config spec: local path, file/HTTP URL, inline payload, or "-" for stdin
431    #[argh(option, short = 'c')]
432    config: Option<String>,
433
434    /// title shown at the top of the UI
435    #[argh(option)]
436    title: Option<String>,
437
438    /// output destinations ("-" writes to stdout). Repeat the flag to add more.
439    #[argh(option, short = 'o', long = "output")]
440    outputs: Vec<String>,
441
442    /// write to PATH when no destinations are set (stdout remains the default)
443    #[argh(option)]
444    temp_file: Option<PathBuf>,
445
446    /// compatibility no-op: stdout is already the default when no destinations are set
447    #[argh(switch)]
448    no_temp_file: bool,
449
450    /// emit compact JSON/TOML rather than pretty formatting
451    #[argh(switch)]
452    no_pretty: bool,
453
454    /// overwrite output files even if they already exist
455    #[argh(switch, short = 'f')]
456    force: bool,
457
458    /// output directory for generated Web snapshots (JSON + TS)
459    #[argh(option, default = "PathBuf::from(\"web_snapshots\")")]
460    out_dir: PathBuf,
461
462    /// name of the exported constant in the generated TS module
463    #[argh(option, default = "String::from(\"SessionSnapshot\")")]
464    ts_export: String,
465}
466
467#[derive(FromArgs, ArgsInfo, Debug, PartialEq)]
468#[argh(
469    subcommand,
470    name = "tui-snapshot",
471    help_triggers("-h", "--help", "help")
472)]
473/// Precompute TUI FormSchema/LayoutNavModel modules instead of launching the UI
474struct ArghTuiSnapshotCommand {
475    /// schema spec: local path, file/HTTP URL, inline payload, or "-" for stdin
476    #[argh(option, short = 's')]
477    schema: Option<String>,
478
479    /// config spec: local path, file/HTTP URL, inline payload, or "-" for stdin
480    #[argh(option, short = 'c')]
481    config: Option<String>,
482
483    /// title shown at the top of the UI
484    #[argh(option)]
485    title: Option<String>,
486
487    /// output destinations ("-" writes to stdout). Repeat the flag to add more.
488    #[argh(option, short = 'o', long = "output")]
489    outputs: Vec<String>,
490
491    /// write to PATH when no destinations are set (stdout remains the default)
492    #[argh(option)]
493    temp_file: Option<PathBuf>,
494
495    /// compatibility no-op: stdout is already the default when no destinations are set
496    #[argh(switch)]
497    no_temp_file: bool,
498
499    /// emit compact JSON/TOML rather than pretty formatting
500    #[argh(switch)]
501    no_pretty: bool,
502
503    /// overwrite output files even if they already exist
504    #[argh(switch, short = 'f')]
505    force: bool,
506
507    /// output directory for generated TUI artifact modules (Rust source)
508    #[argh(option, default = "PathBuf::from(\"tui_artifacts\")")]
509    out_dir: PathBuf,
510
511    /// name of the generated TuiArtifacts constructor function
512    #[argh(option, default = "String::from(\"tui_artifacts\")")]
513    tui_fn: String,
514
515    /// name of the generated FormSchema constructor function
516    #[argh(option, default = "String::from(\"tui_form_schema\")")]
517    form_fn: String,
518
519    /// name of the generated LayoutNavModel constructor function
520    #[argh(option, default = "String::from(\"tui_layout_nav\")")]
521    layout_fn: String,
522}
523
524#[cfg(feature = "web")]
525fn default_host() -> IpAddr {
526    IpAddr::from([127, 0, 0, 1])
527}
528
529#[derive(Debug, Clone, Copy, PartialEq, Eq)]
530enum CommandScan {
531    None,
532    Help,
533    Explicit,
534}
535
536fn scan_for_command(args: &[String]) -> CommandScan {
537    let mut index = 0usize;
538    while index < args.len() {
539        let token = args[index].as_str();
540        if is_help_trigger(token) {
541            return CommandScan::Help;
542        }
543        if is_known_subcommand(token) {
544            return CommandScan::Explicit;
545        }
546        if consumes_multiple_values(token) {
547            index += 1;
548            while index < args.len() {
549                let next = args[index].as_str();
550                if next.starts_with('-') || is_known_subcommand(next) || is_help_trigger(next) {
551                    break;
552                }
553                index += 1;
554            }
555            continue;
556        }
557        if consumes_single_value(token) {
558            index += 2;
559            continue;
560        }
561        index += 1;
562    }
563    CommandScan::None
564}
565
566fn normalize_args(args: &[String]) -> Vec<String> {
567    let mut normalized = Vec::new();
568    let mut index = 0usize;
569    let mut segment_start = 0usize;
570
571    while index < args.len() {
572        let token = args[index].as_str();
573
574        if let Some((flag, value)) = normalize_inline_option(token) {
575            if consumes_single_value(&flag) {
576                upsert_single_value_option(&mut normalized, segment_start, flag, value);
577            } else {
578                normalized.push(flag);
579                normalized.push(value);
580            }
581            index += 1;
582            continue;
583        }
584
585        let token = match token {
586            "--data" => "--config",
587            "--bind" | "--listen" => "--host",
588            "-y" | "--yes" => "--force",
589            other => other,
590        };
591
592        if is_known_subcommand(token) {
593            normalized.push(token.to_string());
594            segment_start = normalized.len();
595            index += 1;
596            continue;
597        }
598
599        if consumes_single_value(token)
600            && let Some(value) = args.get(index + 1)
601        {
602            upsert_single_value_option(
603                &mut normalized,
604                segment_start,
605                token.to_string(),
606                value.clone(),
607            );
608            index += 2;
609            continue;
610        }
611
612        normalized.push(token.to_string());
613        index += 1;
614    }
615
616    normalized
617}
618
619fn upsert_single_value_option(
620    normalized: &mut Vec<String>,
621    segment_start: usize,
622    flag: String,
623    value: String,
624) {
625    if let Some(position) = normalized[segment_start..]
626        .windows(2)
627        .position(|window| window[0] == flag)
628    {
629        normalized[segment_start + position + 1] = value;
630        return;
631    }
632
633    normalized.push(flag);
634    normalized.push(value);
635}
636
637fn normalize_inline_option(token: &str) -> Option<(String, String)> {
638    const INLINE_ALIASES: &[(&str, &str)] = &[
639        ("--schema=", "--schema"),
640        ("--config=", "--config"),
641        ("--data=", "--config"),
642        ("--title=", "--title"),
643        ("--output=", "--output"),
644        ("--temp-file=", "--temp-file"),
645        ("--host=", "--host"),
646        ("--bind=", "--host"),
647        ("--listen=", "--host"),
648        ("--port=", "--port"),
649        ("--out-dir=", "--out-dir"),
650        ("--tui-fn=", "--tui-fn"),
651        ("--form-fn=", "--form-fn"),
652        ("--layout-fn=", "--layout-fn"),
653        ("--ts-export=", "--ts-export"),
654    ];
655
656    for (prefix, canonical) in INLINE_ALIASES {
657        if let Some(value) = token.strip_prefix(prefix) {
658            return Some(((*canonical).to_string(), value.to_string()));
659        }
660    }
661    None
662}
663
664fn expand_output_values(args: &[String]) -> Vec<String> {
665    let mut expanded = Vec::new();
666    let mut index = 0usize;
667    while index < args.len() {
668        let token = args[index].as_str();
669        if consumes_multiple_values(token) {
670            let canonical = "--output".to_string();
671            expanded.push(canonical.clone());
672            index += 1;
673
674            let mut consumed_any = false;
675            while index < args.len() {
676                let next = args[index].as_str();
677                if next.starts_with('-') || is_known_subcommand(next) {
678                    break;
679                }
680
681                if consumed_any {
682                    expanded.push(canonical.clone());
683                }
684                expanded.push(args[index].clone());
685                consumed_any = true;
686                index += 1;
687            }
688            continue;
689        }
690
691        expanded.push(args[index].clone());
692        index += 1;
693    }
694    expanded
695}
696
697fn consumes_single_value(token: &str) -> bool {
698    matches!(
699        token,
700        "-s" | "--schema"
701            | "-c"
702            | "--config"
703            | "--title"
704            | "--temp-file"
705            | "-l"
706            | "--host"
707            | "-p"
708            | "--port"
709            | "--out-dir"
710            | "--tui-fn"
711            | "--form-fn"
712            | "--layout-fn"
713            | "--ts-export"
714    )
715}
716
717fn consumes_multiple_values(token: &str) -> bool {
718    matches!(token, "-o" | "--output")
719}
720
721fn is_help_trigger(token: &str) -> bool {
722    matches!(token, "-h" | "--help" | "help")
723}
724
725fn is_known_subcommand(token: &str) -> bool {
726    matches!(token, "completion" | "tui" | "tui-snapshot")
727        || cfg!(feature = "web") && matches!(token, "web" | "web-snapshot")
728}