Skip to main content

clapfig/
cli.rs

1//! Clap adapter for clapfig.
2//!
3//! This module is the **optional integration layer** between clapfig's
4//! framework-agnostic core and the [clap](https://docs.rs/clap) CLI parser.
5//! It is compiled only when the `clap` Cargo feature is enabled (on by
6//! default).
7//!
8//! The module provides two clap derive types — [`ConfigArgs`] and
9//! [`ConfigSubcommand`] — that you can embed directly into your clap
10//! `#[derive(Parser)]` struct to get `config gen|list|get|set|unset` subcommands
11//! with no boilerplate.
12//!
13//! The only bridge to the core is [`ConfigArgs::into_action()`], which
14//! converts clap-parsed arguments into a [`ConfigAction`](crate::ConfigAction).
15//! From there, all logic flows through the clap-free
16//! [`ClapfigBuilder::handle()`](crate::ClapfigBuilder::handle) API.
17//!
18//! If you use a different CLI parser (or no CLI at all), you can skip this
19//! module entirely and construct [`ConfigAction`](crate::ConfigAction) values
20//! directly.
21
22use std::path::PathBuf;
23
24use clap::{Arg, ArgMatches, Args, Command, Subcommand};
25
26use crate::error::ClapfigError;
27use crate::types::ConfigAction;
28
29/// Clap-derived args for the `config` subcommand group.
30///
31/// Embed this into your app's clap derive:
32/// ```ignore
33/// #[derive(Parser)]
34/// struct Cli {
35///     #[command(subcommand)]
36///     command: Commands,
37/// }
38///
39/// #[derive(Subcommand)]
40/// enum Commands {
41///     Config(ConfigArgs),
42/// }
43/// ```
44#[derive(Debug, Args)]
45pub struct ConfigArgs {
46    /// Target a named persist scope (e.g. "local", "global").
47    ///
48    /// For `set`/`unset`: selects which config file to write to. Defaults to the
49    /// first scope configured on the builder.
50    ///
51    /// For `list`/`get`: reads from that scope's config file only (instead of
52    /// the merged resolved view).
53    #[arg(long, global = true)]
54    pub scope: Option<String>,
55
56    #[command(subcommand)]
57    pub action: Option<ConfigSubcommand>,
58}
59
60/// Available config subcommands.
61#[derive(Debug, Subcommand)]
62pub enum ConfigSubcommand {
63    /// Show all resolved configuration key-value pairs.
64    List,
65    /// Generate a commented sample configuration file.
66    Gen {
67        /// Write to a file instead of stdout.
68        #[arg(short, long)]
69        output: Option<PathBuf>,
70    },
71    /// Emit a JSON Schema document describing the config struct.
72    Schema {
73        /// Write to a file instead of stdout.
74        #[arg(short, long)]
75        output: Option<PathBuf>,
76    },
77    /// Show the resolved value and documentation for a config key.
78    Get {
79        /// Dotted key path (e.g. "database.url").
80        key: String,
81    },
82    /// Persist a configuration value to the config file.
83    Set {
84        /// Dotted key path (e.g. "database.url").
85        key: String,
86        /// Value to set.
87        value: String,
88    },
89    /// Remove a configuration value from the config file.
90    Unset {
91        /// Dotted key path (e.g. "database.url").
92        key: String,
93    },
94}
95
96impl ConfigArgs {
97    /// Convert clap-parsed args into a framework-agnostic `ConfigAction`.
98    ///
99    /// Bare `config` (no subcommand) and explicit `config list` both map to
100    /// `ConfigAction::List`. The `--scope` flag is threaded through to all
101    /// variants except `Gen`.
102    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
115/// Runtime-configurable alternative to [`ConfigArgs`] for apps that need
116/// to rename subcommands or flags to avoid conflicts.
117///
118/// Both `ConfigArgs` (derive) and `ConfigCommand` (builder) produce
119/// [`ConfigAction`], so all downstream logic is shared.
120///
121/// # Example
122///
123/// ```ignore
124/// use clapfig::ConfigCommand;
125///
126/// let config_cmd = ConfigCommand::new()
127///     .scope_long("target")       // rename --scope to --target
128///     .gen_name("template");      // rename "gen" to "template"
129///
130/// let app = Cli::command()
131///     .subcommand(config_cmd.as_command("settings"));
132///
133/// let matches = app.get_matches();
134/// if let Some(("settings", sub)) = matches.subcommand() {
135///     let action = config_cmd.parse(sub)?;
136///     builder.handle_and_print(&action)?;
137/// }
138/// ```
139pub 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    /// Create a new `ConfigCommand` with default names matching [`ConfigArgs`].
169    pub fn new() -> Self {
170        Self::default()
171    }
172
173    /// Rename the `list` subcommand.
174    pub fn list_name(mut self, name: impl Into<String>) -> Self {
175        self.list_name = name.into();
176        self
177    }
178
179    /// Rename the `gen` subcommand.
180    pub fn gen_name(mut self, name: impl Into<String>) -> Self {
181        self.gen_name = name.into();
182        self
183    }
184
185    /// Rename the `schema` subcommand.
186    pub fn schema_name(mut self, name: impl Into<String>) -> Self {
187        self.schema_name = name.into();
188        self
189    }
190
191    /// Rename the `get` subcommand.
192    pub fn get_name(mut self, name: impl Into<String>) -> Self {
193        self.get_name = name.into();
194        self
195    }
196
197    /// Rename the `set` subcommand.
198    pub fn set_name(mut self, name: impl Into<String>) -> Self {
199        self.set_name = name.into();
200        self
201    }
202
203    /// Rename the `unset` subcommand.
204    pub fn unset_name(mut self, name: impl Into<String>) -> Self {
205        self.unset_name = name.into();
206        self
207    }
208
209    /// Rename the `--scope` flag.
210    pub fn scope_long(mut self, name: impl Into<String>) -> Self {
211        self.scope_long = name.into();
212        self
213    }
214
215    /// Rename the `--output` flag on the `gen` subcommand.
216    pub fn output_long(mut self, name: impl Into<String>) -> Self {
217        self.output_long = name.into();
218        self
219    }
220
221    /// Set or disable the short flag for `--output` (default: `Some('o')`).
222    /// Pass `None` to remove the short flag entirely.
223    pub fn output_short(mut self, short: Option<char>) -> Self {
224        self.output_short = short;
225        self
226    }
227
228    /// Build a [`clap::Command`] with the configured names.
229    ///
230    /// The `name` parameter sets the top-level subcommand name
231    /// (e.g. `"config"`, `"settings"`).
232    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    /// Extract a [`ConfigAction`] from parsed [`ArgMatches`].
298    ///
299    /// Bare invocation (no subcommand) maps to `ConfigAction::List`,
300    /// matching the behavior of [`ConfigArgs::into_action`].
301    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    /// Wrapper so we can use `try_parse_from` on the subcommand.
339    #[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    // --- scope flag tests ---
474
475    #[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    // =======================================================================
554    // ConfigCommand tests
555    // =======================================================================
556
557    /// Helper: build a top-level Command wrapping ConfigCommand, parse, and
558    /// return the ConfigAction.
559    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    // --- default names (should match ConfigArgs behavior) ---
567
568    #[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    // --- renamed subcommands ---
699
700    #[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    // --- renamed flags ---
756
757    #[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        // Long form still works
798        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        // Short form should fail
805        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    // --- scope positioning ---
813
814    #[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    // --- custom top-level name ---
831
832    #[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    // --- multiple renames composed ---
851
852    #[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        // write with --target
867        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        // template with -f
884        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}