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"))]
275struct ArghCli {
277 #[argh(option, short = 's')]
279 schema: Option<String>,
280
281 #[argh(option, short = 'c')]
283 config: Option<String>,
284
285 #[argh(option)]
287 title: Option<String>,
288
289 #[argh(option, short = 'o', long = "output")]
291 outputs: Vec<String>,
292
293 #[argh(option)]
295 temp_file: Option<PathBuf>,
296
297 #[argh(switch)]
299 no_temp_file: bool,
300
301 #[argh(switch)]
303 no_pretty: bool,
304
305 #[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#[argh(subcommand, name = "completion", help_triggers("-h", "--help", "help"))]
328struct ArghCompletionCommand {
329 #[argh(positional)]
331 shell: CompletionShell,
332}
333
334#[derive(FromArgs, ArgsInfo, Debug, PartialEq)]
335#[argh(subcommand, name = "tui", help_triggers("-h", "--help", "help"))]
336struct ArghTuiCommand {
338 #[argh(option, short = 's')]
340 schema: Option<String>,
341
342 #[argh(option, short = 'c')]
344 config: Option<String>,
345
346 #[argh(option)]
348 title: Option<String>,
349
350 #[argh(option, short = 'o', long = "output")]
352 outputs: Vec<String>,
353
354 #[argh(option)]
356 temp_file: Option<PathBuf>,
357
358 #[argh(switch)]
360 no_temp_file: bool,
361
362 #[argh(switch)]
364 no_pretty: bool,
365
366 #[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"))]
374struct ArghWebCommand {
376 #[argh(option, short = 's')]
378 schema: Option<String>,
379
380 #[argh(option, short = 'c')]
382 config: Option<String>,
383
384 #[argh(option)]
386 title: Option<String>,
387
388 #[argh(option, short = 'o', long = "output")]
390 outputs: Vec<String>,
391
392 #[argh(option)]
394 temp_file: Option<PathBuf>,
395
396 #[argh(switch)]
398 no_temp_file: bool,
399
400 #[argh(switch)]
402 no_pretty: bool,
403
404 #[argh(switch, short = 'f')]
406 force: bool,
407
408 #[argh(option, short = 'l', default = "default_host()")]
410 host: IpAddr,
411
412 #[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)]
424struct ArghWebSnapshotCommand {
426 #[argh(option, short = 's')]
428 schema: Option<String>,
429
430 #[argh(option, short = 'c')]
432 config: Option<String>,
433
434 #[argh(option)]
436 title: Option<String>,
437
438 #[argh(option, short = 'o', long = "output")]
440 outputs: Vec<String>,
441
442 #[argh(option)]
444 temp_file: Option<PathBuf>,
445
446 #[argh(switch)]
448 no_temp_file: bool,
449
450 #[argh(switch)]
452 no_pretty: bool,
453
454 #[argh(switch, short = 'f')]
456 force: bool,
457
458 #[argh(option, default = "PathBuf::from(\"web_snapshots\")")]
460 out_dir: PathBuf,
461
462 #[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)]
473struct ArghTuiSnapshotCommand {
475 #[argh(option, short = 's')]
477 schema: Option<String>,
478
479 #[argh(option, short = 'c')]
481 config: Option<String>,
482
483 #[argh(option)]
485 title: Option<String>,
486
487 #[argh(option, short = 'o', long = "output")]
489 outputs: Vec<String>,
490
491 #[argh(option)]
493 temp_file: Option<PathBuf>,
494
495 #[argh(switch)]
497 no_temp_file: bool,
498
499 #[argh(switch)]
501 no_pretty: bool,
502
503 #[argh(switch, short = 'f')]
505 force: bool,
506
507 #[argh(option, default = "PathBuf::from(\"tui_artifacts\")")]
509 out_dir: PathBuf,
510
511 #[argh(option, default = "String::from(\"tui_artifacts\")")]
513 tui_fn: String,
514
515 #[argh(option, default = "String::from(\"tui_form_schema\")")]
517 form_fn: String,
518
519 #[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}