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