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"))]
285struct ArghCli {
287 #[argh(option, short = 's')]
289 schema: Option<String>,
290
291 #[argh(option, short = 'c')]
293 config: Option<String>,
294
295 #[argh(option)]
297 title: Option<String>,
298
299 #[argh(option)]
301 description: Option<String>,
302
303 #[argh(option, short = 'o', long = "output")]
305 outputs: Vec<String>,
306
307 #[argh(option)]
309 temp_file: Option<PathBuf>,
310
311 #[argh(switch)]
313 no_temp_file: bool,
314
315 #[argh(switch)]
317 no_pretty: bool,
318
319 #[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#[argh(subcommand, name = "completion", help_triggers("-h", "--help", "help"))]
342struct ArghCompletionCommand {
343 #[argh(positional)]
345 shell: CompletionShell,
346}
347
348#[derive(FromArgs, ArgsInfo, Debug, PartialEq)]
349#[argh(subcommand, name = "tui", help_triggers("-h", "--help", "help"))]
350struct ArghTuiCommand {
352 #[argh(option, short = 's')]
354 schema: Option<String>,
355
356 #[argh(option, short = 'c')]
358 config: Option<String>,
359
360 #[argh(option)]
362 title: Option<String>,
363
364 #[argh(option)]
366 description: Option<String>,
367
368 #[argh(option, short = 'o', long = "output")]
370 outputs: Vec<String>,
371
372 #[argh(option)]
374 temp_file: Option<PathBuf>,
375
376 #[argh(switch)]
378 no_temp_file: bool,
379
380 #[argh(switch)]
382 no_pretty: bool,
383
384 #[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"))]
392struct ArghWebCommand {
394 #[argh(option, short = 's')]
396 schema: Option<String>,
397
398 #[argh(option, short = 'c')]
400 config: Option<String>,
401
402 #[argh(option)]
404 title: Option<String>,
405
406 #[argh(option)]
408 description: Option<String>,
409
410 #[argh(option, short = 'o', long = "output")]
412 outputs: Vec<String>,
413
414 #[argh(option)]
416 temp_file: Option<PathBuf>,
417
418 #[argh(switch)]
420 no_temp_file: bool,
421
422 #[argh(switch)]
424 no_pretty: bool,
425
426 #[argh(switch, short = 'f')]
428 force: bool,
429
430 #[argh(option, short = 'l', default = "default_host()")]
432 host: IpAddr,
433
434 #[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)]
446struct ArghWebSnapshotCommand {
448 #[argh(option, short = 's')]
450 schema: Option<String>,
451
452 #[argh(option, short = 'c')]
454 config: Option<String>,
455
456 #[argh(option)]
458 title: Option<String>,
459
460 #[argh(option)]
462 description: Option<String>,
463
464 #[argh(option, short = 'o', long = "output")]
466 outputs: Vec<String>,
467
468 #[argh(option)]
470 temp_file: Option<PathBuf>,
471
472 #[argh(switch)]
474 no_temp_file: bool,
475
476 #[argh(switch)]
478 no_pretty: bool,
479
480 #[argh(switch, short = 'f')]
482 force: bool,
483
484 #[argh(option, default = "PathBuf::from(\"web_snapshots\")")]
486 out_dir: PathBuf,
487
488 #[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)]
499struct ArghTuiSnapshotCommand {
501 #[argh(option, short = 's')]
503 schema: Option<String>,
504
505 #[argh(option, short = 'c')]
507 config: Option<String>,
508
509 #[argh(option)]
511 title: Option<String>,
512
513 #[argh(option)]
515 description: Option<String>,
516
517 #[argh(option, short = 'o', long = "output")]
519 outputs: Vec<String>,
520
521 #[argh(option)]
523 temp_file: Option<PathBuf>,
524
525 #[argh(switch)]
527 no_temp_file: bool,
528
529 #[argh(switch)]
531 no_pretty: bool,
532
533 #[argh(switch, short = 'f')]
535 force: bool,
536
537 #[argh(option, default = "PathBuf::from(\"tui_artifacts\")")]
539 out_dir: PathBuf,
540
541 #[argh(option, default = "String::from(\"tui_artifacts\")")]
543 tui_fn: String,
544
545 #[argh(option, default = "String::from(\"tui_form_schema\")")]
547 form_fn: String,
548
549 #[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}