1use std::path::PathBuf;
23
24use clap::{Arg, ArgMatches, Args, Command, Subcommand};
25
26use crate::error::ClapfigError;
27use crate::types::ConfigAction;
28
29#[derive(Debug, Args)]
45pub struct ConfigArgs {
46 #[arg(long, global = true)]
54 pub scope: Option<String>,
55
56 #[command(subcommand)]
57 pub action: Option<ConfigSubcommand>,
58}
59
60#[derive(Debug, Subcommand)]
62pub enum ConfigSubcommand {
63 List,
65 Gen {
67 #[arg(short, long)]
69 output: Option<PathBuf>,
70 },
71 Schema {
73 #[arg(short, long)]
75 output: Option<PathBuf>,
76 },
77 Get {
79 key: String,
81 },
82 Set {
84 key: String,
86 value: String,
88 },
89 Unset {
91 key: String,
93 },
94}
95
96impl ConfigArgs {
97 pub fn into_action(self) -> ConfigAction {
103 let scope = self.scope;
104 match self.action {
105 None | Some(ConfigSubcommand::List) => ConfigAction::List { scope },
106 Some(ConfigSubcommand::Gen { output }) => ConfigAction::Gen { output },
107 Some(ConfigSubcommand::Schema { output }) => ConfigAction::Schema { output },
108 Some(ConfigSubcommand::Get { key }) => ConfigAction::Get { key, scope },
109 Some(ConfigSubcommand::Set { key, value }) => ConfigAction::Set { key, value, scope },
110 Some(ConfigSubcommand::Unset { key }) => ConfigAction::Unset { key, scope },
111 }
112 }
113}
114
115pub struct ConfigCommand {
140 list_name: String,
141 gen_name: String,
142 schema_name: String,
143 get_name: String,
144 set_name: String,
145 unset_name: String,
146 scope_long: String,
147 output_long: String,
148 output_short: Option<char>,
149}
150
151impl Default for ConfigCommand {
152 fn default() -> Self {
153 Self {
154 list_name: "list".into(),
155 gen_name: "gen".into(),
156 schema_name: "schema".into(),
157 get_name: "get".into(),
158 set_name: "set".into(),
159 unset_name: "unset".into(),
160 scope_long: "scope".into(),
161 output_long: "output".into(),
162 output_short: Some('o'),
163 }
164 }
165}
166
167impl ConfigCommand {
168 pub fn new() -> Self {
170 Self::default()
171 }
172
173 pub fn list_name(mut self, name: impl Into<String>) -> Self {
175 self.list_name = name.into();
176 self
177 }
178
179 pub fn gen_name(mut self, name: impl Into<String>) -> Self {
181 self.gen_name = name.into();
182 self
183 }
184
185 pub fn schema_name(mut self, name: impl Into<String>) -> Self {
187 self.schema_name = name.into();
188 self
189 }
190
191 pub fn get_name(mut self, name: impl Into<String>) -> Self {
193 self.get_name = name.into();
194 self
195 }
196
197 pub fn set_name(mut self, name: impl Into<String>) -> Self {
199 self.set_name = name.into();
200 self
201 }
202
203 pub fn unset_name(mut self, name: impl Into<String>) -> Self {
205 self.unset_name = name.into();
206 self
207 }
208
209 pub fn scope_long(mut self, name: impl Into<String>) -> Self {
211 self.scope_long = name.into();
212 self
213 }
214
215 pub fn output_long(mut self, name: impl Into<String>) -> Self {
217 self.output_long = name.into();
218 self
219 }
220
221 pub fn output_short(mut self, short: Option<char>) -> Self {
224 self.output_short = short;
225 self
226 }
227
228 pub fn as_command(&self, name: &str) -> Command {
233 let scope_arg = Arg::new("scope")
234 .long(self.scope_long.clone())
235 .help("Target a named persist scope (e.g. \"local\", \"global\").")
236 .global(true);
237
238 let build_output_arg = || {
239 let mut arg = Arg::new("output")
240 .long(self.output_long.clone())
241 .help("Write to a file instead of stdout.")
242 .value_parser(clap::value_parser!(PathBuf));
243 if let Some(short) = self.output_short {
244 arg = arg.short(short);
245 }
246 arg
247 };
248
249 let list_cmd = Command::new(self.list_name.clone())
250 .about("Show all resolved configuration key-value pairs.");
251
252 let gen_cmd = Command::new(self.gen_name.clone())
253 .about("Generate a commented sample configuration file.")
254 .arg(build_output_arg());
255
256 let schema_cmd = Command::new(self.schema_name.clone())
257 .about("Emit a JSON Schema document describing the config struct.")
258 .arg(build_output_arg());
259
260 let get_cmd = Command::new(self.get_name.clone())
261 .about("Show the resolved value and documentation for a config key.")
262 .arg(
263 Arg::new("key")
264 .required(true)
265 .help("Dotted key path (e.g. \"database.url\")."),
266 );
267
268 let set_cmd = Command::new(self.set_name.clone())
269 .about("Persist a configuration value to the config file.")
270 .arg(
271 Arg::new("key")
272 .required(true)
273 .help("Dotted key path (e.g. \"database.url\")."),
274 )
275 .arg(Arg::new("value").required(true).help("Value to set."));
276
277 let unset_cmd = Command::new(self.unset_name.clone())
278 .about("Remove a configuration value from the config file.")
279 .arg(
280 Arg::new("key")
281 .required(true)
282 .help("Dotted key path (e.g. \"database.url\")."),
283 );
284
285 Command::new(name.to_owned())
286 .about("Manage configuration.")
287 .subcommand_required(false)
288 .arg(scope_arg)
289 .subcommand(list_cmd)
290 .subcommand(gen_cmd)
291 .subcommand(schema_cmd)
292 .subcommand(get_cmd)
293 .subcommand(set_cmd)
294 .subcommand(unset_cmd)
295 }
296
297 pub fn parse(&self, matches: &ArgMatches) -> Result<ConfigAction, ClapfigError> {
302 let scope = matches.get_one::<String>("scope").cloned();
303
304 match matches.subcommand() {
305 None => Ok(ConfigAction::List { scope }),
306 Some((name, _)) if name == self.list_name => Ok(ConfigAction::List { scope }),
307 Some((name, sub)) if name == self.gen_name => {
308 let output = sub.get_one::<PathBuf>("output").cloned();
309 Ok(ConfigAction::Gen { output })
310 }
311 Some((name, sub)) if name == self.schema_name => {
312 let output = sub.get_one::<PathBuf>("output").cloned();
313 Ok(ConfigAction::Schema { output })
314 }
315 Some((name, sub)) if name == self.get_name => {
316 let key = sub.get_one::<String>("key").unwrap().clone();
317 Ok(ConfigAction::Get { key, scope })
318 }
319 Some((name, sub)) if name == self.set_name => {
320 let key = sub.get_one::<String>("key").unwrap().clone();
321 let value = sub.get_one::<String>("value").unwrap().clone();
322 Ok(ConfigAction::Set { key, value, scope })
323 }
324 Some((name, sub)) if name == self.unset_name => {
325 let key = sub.get_one::<String>("key").unwrap().clone();
326 Ok(ConfigAction::Unset { key, scope })
327 }
328 Some((name, _)) => Err(ClapfigError::UnknownSubcommand(name.to_owned())),
329 }
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use clap::Parser;
337
338 #[derive(Debug, Parser)]
340 struct TestCli {
341 #[command(flatten)]
342 config: ConfigArgs,
343 }
344
345 fn parse(args: &[&str]) -> ConfigArgs {
346 TestCli::try_parse_from(args).unwrap().config
347 }
348
349 #[test]
350 fn parse_gen_no_output() {
351 let args = parse(&["test", "gen"]);
352 let action = args.into_action();
353 assert_eq!(action, ConfigAction::Gen { output: None });
354 }
355
356 #[test]
357 fn parse_gen_with_output() {
358 let args = parse(&["test", "gen", "-o", "out.toml"]);
359 let action = args.into_action();
360 assert_eq!(
361 action,
362 ConfigAction::Gen {
363 output: Some(PathBuf::from("out.toml"))
364 }
365 );
366 }
367
368 #[test]
369 fn parse_schema_no_output() {
370 let args = parse(&["test", "schema"]);
371 let action = args.into_action();
372 assert_eq!(action, ConfigAction::Schema { output: None });
373 }
374
375 #[test]
376 fn parse_schema_with_output() {
377 let args = parse(&["test", "schema", "-o", "schema.json"]);
378 let action = args.into_action();
379 assert_eq!(
380 action,
381 ConfigAction::Schema {
382 output: Some(PathBuf::from("schema.json"))
383 }
384 );
385 }
386
387 #[test]
388 fn parse_gen_with_long_output() {
389 let args = parse(&["test", "gen", "--output", "/etc/myapp.toml"]);
390 let action = args.into_action();
391 assert_eq!(
392 action,
393 ConfigAction::Gen {
394 output: Some(PathBuf::from("/etc/myapp.toml"))
395 }
396 );
397 }
398
399 #[test]
400 fn parse_get() {
401 let args = parse(&["test", "get", "database.url"]);
402 let action = args.into_action();
403 assert_eq!(
404 action,
405 ConfigAction::Get {
406 key: "database.url".into(),
407 scope: None,
408 }
409 );
410 }
411
412 #[test]
413 fn parse_set() {
414 let args = parse(&["test", "set", "port", "3000"]);
415 let action = args.into_action();
416 assert_eq!(
417 action,
418 ConfigAction::Set {
419 key: "port".into(),
420 value: "3000".into(),
421 scope: None,
422 }
423 );
424 }
425
426 #[test]
427 fn parse_set_string_value() {
428 let args = parse(&["test", "set", "host", "0.0.0.0"]);
429 let action = args.into_action();
430 assert_eq!(
431 action,
432 ConfigAction::Set {
433 key: "host".into(),
434 value: "0.0.0.0".into(),
435 scope: None,
436 }
437 );
438 }
439
440 #[test]
441 fn invalid_subcommand_errors() {
442 let result = TestCli::try_parse_from(["test", "nope"]);
443 assert!(result.is_err());
444 }
445
446 #[test]
447 fn parse_unset() {
448 let args = parse(&["test", "unset", "database.url"]);
449 let action = args.into_action();
450 assert_eq!(
451 action,
452 ConfigAction::Unset {
453 key: "database.url".into(),
454 scope: None,
455 }
456 );
457 }
458
459 #[test]
460 fn parse_bare_config_is_list() {
461 let args = parse(&["test"]);
462 let action = args.into_action();
463 assert_eq!(action, ConfigAction::List { scope: None });
464 }
465
466 #[test]
467 fn parse_explicit_list() {
468 let args = parse(&["test", "list"]);
469 let action = args.into_action();
470 assert_eq!(action, ConfigAction::List { scope: None });
471 }
472
473 #[test]
476 fn parse_set_with_scope() {
477 let args = parse(&["test", "set", "port", "3000", "--scope", "global"]);
478 let action = args.into_action();
479 assert_eq!(
480 action,
481 ConfigAction::Set {
482 key: "port".into(),
483 value: "3000".into(),
484 scope: Some("global".into()),
485 }
486 );
487 }
488
489 #[test]
490 fn parse_scope_before_subcommand() {
491 let args = parse(&["test", "--scope", "global", "set", "port", "3000"]);
492 let action = args.into_action();
493 assert_eq!(
494 action,
495 ConfigAction::Set {
496 key: "port".into(),
497 value: "3000".into(),
498 scope: Some("global".into()),
499 }
500 );
501 }
502
503 #[test]
504 fn parse_list_with_scope() {
505 let args = parse(&["test", "list", "--scope", "global"]);
506 let action = args.into_action();
507 assert_eq!(
508 action,
509 ConfigAction::List {
510 scope: Some("global".into()),
511 }
512 );
513 }
514
515 #[test]
516 fn parse_get_with_scope() {
517 let args = parse(&["test", "get", "port", "--scope", "local"]);
518 let action = args.into_action();
519 assert_eq!(
520 action,
521 ConfigAction::Get {
522 key: "port".into(),
523 scope: Some("local".into()),
524 }
525 );
526 }
527
528 #[test]
529 fn parse_unset_with_scope() {
530 let args = parse(&["test", "unset", "port", "--scope", "global"]);
531 let action = args.into_action();
532 assert_eq!(
533 action,
534 ConfigAction::Unset {
535 key: "port".into(),
536 scope: Some("global".into()),
537 }
538 );
539 }
540
541 #[test]
542 fn parse_bare_config_with_scope() {
543 let args = parse(&["test", "--scope", "global"]);
544 let action = args.into_action();
545 assert_eq!(
546 action,
547 ConfigAction::List {
548 scope: Some("global".into()),
549 }
550 );
551 }
552
553 fn cmd_parse(cmd: &ConfigCommand, args: &[&str]) -> ConfigAction {
560 let app = Command::new("test").subcommand(cmd.as_command("config"));
561 let matches = app.try_get_matches_from(args).unwrap();
562 let (_, sub) = matches.subcommand().unwrap();
563 cmd.parse(sub).unwrap()
564 }
565
566 #[test]
569 fn cmd_default_bare_is_list() {
570 let cmd = ConfigCommand::new();
571 let app = Command::new("test").subcommand(cmd.as_command("config"));
572 let matches = app.try_get_matches_from(["test", "config"]).unwrap();
573 let (_, sub) = matches.subcommand().unwrap();
574 assert_eq!(cmd.parse(sub).unwrap(), ConfigAction::List { scope: None });
575 }
576
577 #[test]
578 fn cmd_default_list() {
579 let cmd = ConfigCommand::new();
580 assert_eq!(
581 cmd_parse(&cmd, &["test", "config", "list"]),
582 ConfigAction::List { scope: None }
583 );
584 }
585
586 #[test]
587 fn cmd_default_gen() {
588 let cmd = ConfigCommand::new();
589 assert_eq!(
590 cmd_parse(&cmd, &["test", "config", "gen"]),
591 ConfigAction::Gen { output: None }
592 );
593 }
594
595 #[test]
596 fn cmd_default_gen_with_output() {
597 let cmd = ConfigCommand::new();
598 assert_eq!(
599 cmd_parse(&cmd, &["test", "config", "gen", "-o", "out.toml"]),
600 ConfigAction::Gen {
601 output: Some(PathBuf::from("out.toml"))
602 }
603 );
604 }
605
606 #[test]
607 fn cmd_default_gen_with_long_output() {
608 let cmd = ConfigCommand::new();
609 assert_eq!(
610 cmd_parse(&cmd, &["test", "config", "gen", "--output", "out.toml"]),
611 ConfigAction::Gen {
612 output: Some(PathBuf::from("out.toml"))
613 }
614 );
615 }
616
617 #[test]
618 fn cmd_default_schema() {
619 let cmd = ConfigCommand::new();
620 assert_eq!(
621 cmd_parse(&cmd, &["test", "config", "schema"]),
622 ConfigAction::Schema { output: None }
623 );
624 }
625
626 #[test]
627 fn cmd_default_schema_with_output() {
628 let cmd = ConfigCommand::new();
629 assert_eq!(
630 cmd_parse(&cmd, &["test", "config", "schema", "-o", "schema.json"]),
631 ConfigAction::Schema {
632 output: Some(PathBuf::from("schema.json"))
633 }
634 );
635 }
636
637 #[test]
638 fn cmd_renamed_schema() {
639 let cmd = ConfigCommand::new().schema_name("json-schema");
640 assert_eq!(
641 cmd_parse(&cmd, &["test", "config", "json-schema"]),
642 ConfigAction::Schema { output: None }
643 );
644 }
645
646 #[test]
647 fn cmd_default_get() {
648 let cmd = ConfigCommand::new();
649 assert_eq!(
650 cmd_parse(&cmd, &["test", "config", "get", "database.url"]),
651 ConfigAction::Get {
652 key: "database.url".into(),
653 scope: None,
654 }
655 );
656 }
657
658 #[test]
659 fn cmd_default_set() {
660 let cmd = ConfigCommand::new();
661 assert_eq!(
662 cmd_parse(&cmd, &["test", "config", "set", "port", "3000"]),
663 ConfigAction::Set {
664 key: "port".into(),
665 value: "3000".into(),
666 scope: None,
667 }
668 );
669 }
670
671 #[test]
672 fn cmd_default_unset() {
673 let cmd = ConfigCommand::new();
674 assert_eq!(
675 cmd_parse(&cmd, &["test", "config", "unset", "port"]),
676 ConfigAction::Unset {
677 key: "port".into(),
678 scope: None,
679 }
680 );
681 }
682
683 #[test]
684 fn cmd_default_scope_flag() {
685 let cmd = ConfigCommand::new();
686 assert_eq!(
687 cmd_parse(
688 &cmd,
689 &["test", "config", "--scope", "global", "get", "port"]
690 ),
691 ConfigAction::Get {
692 key: "port".into(),
693 scope: Some("global".into()),
694 }
695 );
696 }
697
698 #[test]
701 fn cmd_renamed_get() {
702 let cmd = ConfigCommand::new().get_name("read");
703 assert_eq!(
704 cmd_parse(&cmd, &["test", "config", "read", "database.url"]),
705 ConfigAction::Get {
706 key: "database.url".into(),
707 scope: None,
708 }
709 );
710 }
711
712 #[test]
713 fn cmd_renamed_set() {
714 let cmd = ConfigCommand::new().set_name("write");
715 assert_eq!(
716 cmd_parse(&cmd, &["test", "config", "write", "port", "3000"]),
717 ConfigAction::Set {
718 key: "port".into(),
719 value: "3000".into(),
720 scope: None,
721 }
722 );
723 }
724
725 #[test]
726 fn cmd_renamed_unset() {
727 let cmd = ConfigCommand::new().unset_name("remove");
728 assert_eq!(
729 cmd_parse(&cmd, &["test", "config", "remove", "port"]),
730 ConfigAction::Unset {
731 key: "port".into(),
732 scope: None,
733 }
734 );
735 }
736
737 #[test]
738 fn cmd_renamed_list() {
739 let cmd = ConfigCommand::new().list_name("show");
740 assert_eq!(
741 cmd_parse(&cmd, &["test", "config", "show"]),
742 ConfigAction::List { scope: None }
743 );
744 }
745
746 #[test]
747 fn cmd_renamed_gen() {
748 let cmd = ConfigCommand::new().gen_name("template");
749 assert_eq!(
750 cmd_parse(&cmd, &["test", "config", "template"]),
751 ConfigAction::Gen { output: None }
752 );
753 }
754
755 #[test]
758 fn cmd_renamed_scope_flag() {
759 let cmd = ConfigCommand::new().scope_long("target");
760 assert_eq!(
761 cmd_parse(
762 &cmd,
763 &["test", "config", "--target", "global", "get", "port"]
764 ),
765 ConfigAction::Get {
766 key: "port".into(),
767 scope: Some("global".into()),
768 }
769 );
770 }
771
772 #[test]
773 fn cmd_renamed_output_long() {
774 let cmd = ConfigCommand::new().output_long("file");
775 assert_eq!(
776 cmd_parse(&cmd, &["test", "config", "gen", "--file", "out.toml"]),
777 ConfigAction::Gen {
778 output: Some(PathBuf::from("out.toml"))
779 }
780 );
781 }
782
783 #[test]
784 fn cmd_renamed_output_short() {
785 let cmd = ConfigCommand::new().output_short(Some('f'));
786 assert_eq!(
787 cmd_parse(&cmd, &["test", "config", "gen", "-f", "out.toml"]),
788 ConfigAction::Gen {
789 output: Some(PathBuf::from("out.toml"))
790 }
791 );
792 }
793
794 #[test]
795 fn cmd_disabled_output_short() {
796 let cmd = ConfigCommand::new().output_short(None);
797 assert_eq!(
799 cmd_parse(&cmd, &["test", "config", "gen", "--output", "out.toml"]),
800 ConfigAction::Gen {
801 output: Some(PathBuf::from("out.toml"))
802 }
803 );
804 let app = Command::new("test").subcommand(cmd.as_command("config"));
806 assert!(
807 app.try_get_matches_from(["test", "config", "gen", "-o", "out.toml"])
808 .is_err()
809 );
810 }
811
812 #[test]
815 fn cmd_scope_after_subcommand() {
816 let cmd = ConfigCommand::new();
817 assert_eq!(
818 cmd_parse(
819 &cmd,
820 &["test", "config", "set", "port", "3000", "--scope", "global"]
821 ),
822 ConfigAction::Set {
823 key: "port".into(),
824 value: "3000".into(),
825 scope: Some("global".into()),
826 }
827 );
828 }
829
830 #[test]
833 fn cmd_custom_top_level_name() {
834 let cmd = ConfigCommand::new();
835 let app = Command::new("test").subcommand(cmd.as_command("settings"));
836 let matches = app
837 .try_get_matches_from(["test", "settings", "get", "port"])
838 .unwrap();
839 let (name, sub) = matches.subcommand().unwrap();
840 assert_eq!(name, "settings");
841 assert_eq!(
842 cmd.parse(sub).unwrap(),
843 ConfigAction::Get {
844 key: "port".into(),
845 scope: None,
846 }
847 );
848 }
849
850 #[test]
853 fn cmd_all_renamed() {
854 let cmd = ConfigCommand::new()
855 .list_name("show")
856 .gen_name("template")
857 .get_name("read")
858 .set_name("write")
859 .unset_name("remove")
860 .scope_long("target")
861 .output_long("file")
862 .output_short(Some('f'));
863
864 let app = Command::new("test").subcommand(cmd.as_command("settings"));
865
866 let m = app
868 .clone()
869 .try_get_matches_from([
870 "test", "settings", "--target", "global", "write", "port", "3000",
871 ])
872 .unwrap();
873 let (_, sub) = m.subcommand().unwrap();
874 assert_eq!(
875 cmd.parse(sub).unwrap(),
876 ConfigAction::Set {
877 key: "port".into(),
878 value: "3000".into(),
879 scope: Some("global".into()),
880 }
881 );
882
883 let m = app
885 .try_get_matches_from(["test", "settings", "template", "-f", "out.toml"])
886 .unwrap();
887 let (_, sub) = m.subcommand().unwrap();
888 assert_eq!(
889 cmd.parse(sub).unwrap(),
890 ConfigAction::Gen {
891 output: Some(PathBuf::from("out.toml"))
892 }
893 );
894 }
895}