Skip to main content

cli_engine/
flags.rs

1use std::collections::BTreeSet;
2use std::io::IsTerminal;
3
4use clap::{Arg, ArgAction, ArgMatches, Command, builder::ValueParser};
5
6/// Returns `true` when the process appears to be running interactively:
7/// stdin and stderr are both TTYs.
8///
9/// Checking stdin ensures that piped input (`echo "" | gddy ...`) is detected
10/// as non-interactive. Checking stderr ensures prompts can be displayed (since
11/// `inquire` renders to stderr). Stdout is intentionally not checked — a user
12/// piping output (`gddy ... | jq`) still has an interactive terminal for
13/// prompts.
14///
15/// Used as the default for `GlobalFlags::interactive` when the user does not
16/// pass `--interactive` or `--non-interactive` explicitly.
17#[must_use]
18pub fn detect_interactive() -> bool {
19    std::io::stdin().is_terminal() && std::io::stderr().is_terminal()
20}
21
22/// Interactivity mode for a CLI invocation.
23///
24/// Commands and middleware can inspect this to decide whether to prompt for
25/// missing inputs, display progress spinners, or fall back to error messages
26/// suitable for scripts and CI.
27#[derive(Clone, Copy, Debug, Eq, PartialEq)]
28pub enum InteractivityMode {
29    /// The user explicitly requested interactive prompts (`--interactive`), or
30    /// the process is running in a TTY without CI indicators.
31    Interactive,
32    /// The user explicitly disabled prompts (`--non-interactive`), or the
33    /// process is running in a non-TTY / CI context.
34    NonInteractive,
35}
36
37impl InteractivityMode {
38    /// Returns `true` when prompts and interactive flows are appropriate.
39    #[must_use]
40    pub fn is_interactive(self) -> bool {
41        self == Self::Interactive
42    }
43}
44
45impl From<bool> for InteractivityMode {
46    fn from(interactive: bool) -> Self {
47        if interactive {
48            Self::Interactive
49        } else {
50            Self::NonInteractive
51        }
52    }
53}
54
55/// Parsed framework-global flags.
56///
57/// Applications can add their own global flags, but these are the built-in
58/// controls understood by middleware and the output pipeline.
59#[derive(Clone, Debug, Eq, PartialEq)]
60pub struct GlobalFlags {
61    /// Output format: `json`, `human`, or `toon`.
62    pub output_format: String,
63    /// Metadata verbosity selector.
64    pub verbose: String,
65    /// Whether mutating commands should short-circuit.
66    pub dry_run: bool,
67    /// Field projection.
68    pub fields: String,
69    /// Whether `fields` came from an explicit `--fields` flag on the command
70    /// line, rather than clap filling in a command's `default_fields` (a
71    /// command with `default_fields` set registers it as that flag's native
72    /// default, so `fields` is non-empty even when the user never typed
73    /// `--fields` — this is the only reliable way to tell the two apart).
74    pub fields_explicit: bool,
75    /// JMESPath per-item filter.
76    pub filter: String,
77    /// JMESPath whole-result expression.
78    pub expr: String,
79    /// Whether schema rendering was requested.
80    pub schema: bool,
81    /// User-provided command reason.
82    pub reason: String,
83    /// Raw timeout string.
84    pub timeout: String,
85    /// Debug selector.
86    pub debug: String,
87    /// Credential storage override from `--credential-store`, if supplied.
88    pub credential_store: Option<crate::config::CredentialStore>,
89    /// Interactivity mode: `true` enables prompts for missing inputs,
90    /// `false` disables them. Auto-detected from TTY when neither flag is given.
91    pub interactive: bool,
92}
93
94impl Default for GlobalFlags {
95    fn default() -> Self {
96        Self {
97            output_format: "json".to_owned(),
98            verbose: String::new(),
99            dry_run: false,
100            fields: String::new(),
101            fields_explicit: false,
102            filter: String::new(),
103            expr: String::new(),
104            schema: false,
105            reason: String::new(),
106            timeout: "0s".to_owned(),
107            debug: String::new(),
108            credential_store: None,
109            interactive: detect_interactive(),
110        }
111    }
112}
113
114/// Explicit `--help` display-order values for the engine's own global flags,
115/// numbered in the order they're registered below — which is meant to read
116/// as their relative importance, most-used first.
117///
118/// Without this, every global flag would collide with command-specific
119/// ones: clap auto-assigns each unset `display_order` as "the Nth argument
120/// added to this `Command`," starting the count over at 0 on every
121/// `Command` it's called on — the root (where these are declared) and each
122/// subcommand alike. A subcommand's own `CommandSpec::with_arg` args get
123/// low counter values (0, 1, 2, ... in declaration order) from their own
124/// `Command`; a global flag propagated onto that subcommand keeps the low
125/// counter value it got on the *root*. Mix the two and `--help` interleaves
126/// them instead of showing command-specific flags first, as a block, in the
127/// order they were declared. Parking every global flag comfortably above
128/// any realistic per-command arg count keeps that from happening.
129///
130/// `FIELDS`, `FILTER`, and `EXPR` are `pub(crate)` because `cli.rs`
131/// re-registers those three per-command (see `apply_fields_arg` and
132/// `apply_filter_and_expr_examples`) with contextual help text; they must
133/// reuse these same values or the override would drift out of position.
134///
135/// `LIMIT` and `OFFSET` are never registered by [`register_global_flags`]
136/// itself — unlike every other value here, `--limit`/`--offset` are not
137/// framework-global at all; `cli.rs` registers them directly on a single
138/// command's own `Command` (see `apply_pagination_args`), and only for a
139/// command that opted in via `CommandSpec::with_pagination`. These two
140/// constants exist purely so that per-command registration still parks the
141/// flags in the same relative `--help` position other engine flags occupy.
142///
143/// `REASON` and `ENV` cover the two global flags `Cli::new` registers
144/// directly (conditionally, outside `register_global_flags`) rather than
145/// this module's own function — `--reason` when an authorizer/auditor/
146/// activity emitter is configured, `--env` when `CliConfig.environments` is
147/// set. Both are just as subject to the collision this module exists to
148/// prevent, so both need an explicit value here too.
149pub(crate) mod global_flag_order {
150    pub(crate) const HELP: usize = 1000;
151    pub(crate) const OUTPUT: usize = 1001;
152    pub(crate) const VERBOSE: usize = 1002;
153    pub(crate) const DRY_RUN: usize = 1003;
154    pub(crate) const FIELDS: usize = 1004;
155    pub(crate) const FILTER: usize = 1005;
156    pub(crate) const EXPR: usize = 1006;
157    pub(crate) const LIMIT: usize = 1007;
158    pub(crate) const OFFSET: usize = 1008;
159    pub(crate) const SCHEMA: usize = 1009;
160    pub(crate) const TIMEOUT: usize = 1010;
161    pub(crate) const DEBUG: usize = 1011;
162    pub(crate) const CREDENTIAL_STORE: usize = 1012;
163    pub(crate) const JSON: usize = 1013;
164    pub(crate) const TOON: usize = 1014;
165    pub(crate) const HUMAN: usize = 1015;
166    pub(crate) const INTERACTIVE: usize = 1016;
167    pub(crate) const REASON: usize = 1017;
168    pub(crate) const ENV: usize = 1018;
169}
170
171/// Registers framework-global flags on a `clap` command.
172pub fn register_global_flags(command: Command) -> Command {
173    command
174        .disable_help_flag(true)
175        .arg(
176            // clap's default help arg shows an abbreviated summary for `-h`
177            // and the full help text for `--help`. Override it so both
178            // flags print the same full help everywhere; `disable_help_flag`
179            // propagates to every subcommand.
180            Arg::new("help")
181                .short('h')
182                .long("help")
183                .action(ArgAction::HelpLong)
184                .global(true)
185                .display_order(global_flag_order::HELP)
186                .help("Print help"),
187        )
188        .arg(
189            Arg::new("output")
190                .long("output")
191                .short('o')
192                .global(true)
193                .display_order(global_flag_order::OUTPUT)
194                .value_name("FORMAT")
195                // This default is cosmetic, not authoritative: it's never
196                // actually read as a value — `global_flags_from_matches` only
197                // consults this arg when it was given on the command line,
198                // falling back to `resolve_default_output_format`'s full
199                // env/config/TTY precedence the rest of the time. But since
200                // `--help` runs in this same process, this process's own
201                // stdout TTY-ness is already known and stable for the whole
202                // run, so mirroring that one signal here (skipping the
203                // env-var/config-file tiers, which aren't available until a
204                // command actually executes) keeps what `--help` shows honest
205                // in the common case instead of a hardcoded, often-wrong
206                // `[default: json]`.
207                .default_value(if std::io::stdout().is_terminal() {
208                    "human"
209                } else {
210                    "json"
211                })
212                // Only conflicts when *explicitly* given: clap's conflict
213                // checks ignore an arg's default value, so a bare `--json`
214                // with no `--output` at all is unaffected.
215                .conflicts_with_all(["json", "toon", "human"])
216                .help(
217                    "Output format: toon|json|human (shorthand: --json, --toon, --human); \
218                     defaults to human in an interactive terminal, json otherwise",
219                ),
220        )
221        .arg(
222            Arg::new("verbose")
223                .long("verbose")
224                .global(true)
225                .num_args(0..=1)
226                .default_missing_value("all")
227                .value_name("FIELDS")
228                .display_order(global_flag_order::VERBOSE)
229                .help("Include metadata in output (all, or comma-separated: system,duration,args,env,identity,command,effective_args,timestamp)"),
230        )
231        .arg(
232            Arg::new("dry-run")
233                .long("dry-run")
234                .global(true)
235                .num_args(0..=1)
236                .require_equals(true)
237                .default_missing_value("true")
238                .default_value("false")
239                .value_parser(compat_bool_value_parser())
240                .display_order(global_flag_order::DRY_RUN)
241                .help("Preview mutations without executing"),
242        )
243        .arg(
244            Arg::new("fields")
245                .long("fields")
246                .global(true)
247                .value_name("FIELDS")
248                .display_order(global_flag_order::FIELDS)
249                .help("Comma-separated fields to include in output (use 'all' or '*' for everything)"),
250        )
251        .arg(
252            Arg::new("filter")
253                .long("filter")
254                .global(true)
255                .value_name("EXPR")
256                .display_order(global_flag_order::FILTER)
257                .help("Per-item JMESPath predicate for list data"),
258        )
259        .arg(
260            Arg::new("expr")
261                .long("expr")
262                .global(true)
263                .value_name("EXPR")
264                .display_order(global_flag_order::EXPR)
265                .help("JMESPath query applied to the whole result"),
266        )
267        .arg(
268            Arg::new("schema")
269                .long("schema")
270                .global(true)
271                .num_args(0..=1)
272                .require_equals(true)
273                .default_missing_value("true")
274                .default_value("false")
275                .value_parser(compat_bool_value_parser())
276                .display_order(global_flag_order::SCHEMA)
277                .help("Dump output field metadata instead of running the command"),
278        )
279        .arg(
280            Arg::new("timeout")
281                .long("timeout")
282                .global(true)
283                .allow_hyphen_values(true)
284                .default_value("0s")
285                .value_name("DURATION")
286                .display_order(global_flag_order::TIMEOUT)
287                .help("Overall command timeout (e.g. 60s, 5m); default 0s = no timeout"),
288        )
289        .arg(
290            Arg::new("debug")
291                .long("debug")
292                .global(true)
293                .num_args(0..=1)
294                .default_missing_value("*")
295                .value_name("PATTERN")
296                .display_order(global_flag_order::DEBUG)
297                .help("Enable debug logging (comma-separated component patterns, e.g. *, transport, *,-auth)"),
298        )
299        .arg(
300            Arg::new("credential-store")
301                .long("credential-store")
302                .display_order(global_flag_order::CREDENTIAL_STORE)
303                .global(true)
304                .value_name("MODE")
305                .value_parser(|s: &str| s.parse::<crate::config::CredentialStore>())
306                .help("Credential storage: auto|keyring|file (overrides env and config)"),
307        )
308        .arg(
309            Arg::new("interactive")
310                .long("interactive")
311                .short('i')
312                .global(true)
313                .action(ArgAction::SetTrue)
314                .conflicts_with("non-interactive")
315                .display_order(global_flag_order::INTERACTIVE)
316                .help("Force interactive prompts for missing inputs (default when TTY is detected)"),
317        )
318        .arg(
319            Arg::new("non-interactive")
320                .long("non-interactive")
321                .global(true)
322                .action(ArgAction::SetTrue)
323                .conflicts_with("interactive")
324                .hide(true)
325                .display_order(global_flag_order::INTERACTIVE)
326                .help("Disable interactive prompts; fail on missing required inputs"),
327        )
328        .arg(
329            Arg::new("json")
330                .long("json")
331                .global(true)
332                .action(ArgAction::SetTrue)
333                // Mutually exclusive with the other format selectors, so
334                // e.g. `--json --human` together is a usage error rather
335                // than one silently overriding the other.
336                .conflicts_with_all(["toon", "human"])
337                // Documented on `--output` instead of taking their own line
338                // in every command's already-long options list.
339                .hide(true)
340                .display_order(global_flag_order::JSON)
341                .help("Shorthand for --output json"),
342        )
343        .arg(
344            Arg::new("toon")
345                .long("toon")
346                .global(true)
347                .action(ArgAction::SetTrue)
348                .conflicts_with_all(["json", "human"])
349                .hide(true)
350                .display_order(global_flag_order::TOON)
351                .help("Shorthand for --output toon"),
352        )
353        .arg(
354            Arg::new("human")
355                .long("human")
356                .global(true)
357                .action(ArgAction::SetTrue)
358                .conflicts_with_all(["json", "toon"])
359                .hide(true)
360                .display_order(global_flag_order::HUMAN)
361                .help("Shorthand for --output human"),
362        )
363}
364
365/// Registers the `--reason` flag on a `clap` command.
366///
367/// Not part of [`register_global_flags`]: `--reason` is only meaningful when an
368/// app has registered an [`Authorizer`](crate::middleware::Authorizer),
369/// [`Auditor`](crate::middleware::Auditor), or
370/// [`ActivityEmitter`](crate::middleware::ActivityEmitter) to consume it (see
371/// `Cli::new`'s conditional call to this function). Apps with none of those
372/// configured never register this flag at all, rather than exposing a flag
373/// that nothing reads. `Cli::new` only checks the eager `authz`/`auditor`/
374/// `activity` fields on `CliConfig`; installing one of these later via
375/// `init_deps` does not register `--reason`, since flag registration happens
376/// before `init_deps` runs.
377pub fn register_reason_flag(command: Command) -> Command {
378    command.arg(
379        Arg::new("reason")
380            .long("reason")
381            .global(true)
382            .value_name("TEXT")
383            .display_order(global_flag_order::REASON)
384            .help("Short explanation of why this command is being run (forwarded to your authorizer, auditor, or activity emitter)"),
385    )
386}
387
388/// Registers `--limit`/`--offset` directly on one command's own `clap`
389/// `Command`, for a command whose [`CommandSpec`](crate::CommandSpec) opted
390/// into pagination via `with_pagination`.
391pub(crate) fn apply_pagination_args(
392    command: Command,
393    default_limit: i64,
394    max_limit: i64,
395) -> Command {
396    command
397        .arg(
398            Arg::new("limit")
399                .long("limit")
400                .value_parser(pagination_limit_value_parser(max_limit))
401                .allow_hyphen_values(true)
402                .default_value(default_limit.to_string())
403                .display_order(global_flag_order::LIMIT)
404                .help(pagination_limit_help(default_limit, max_limit)),
405        )
406        .arg(
407            Arg::new("offset")
408                .long("offset")
409                .value_parser(pagination_offset_value_parser())
410                .allow_hyphen_values(true)
411                .default_value("0")
412                .display_order(global_flag_order::OFFSET)
413                .help("Skip N items before applying limit"),
414        )
415}
416
417fn pagination_limit_help(default_limit: i64, max_limit: i64) -> String {
418    let mut help = format!("Max items to return (client-side, 0=all, default {default_limit}");
419    if max_limit > 0 {
420        help.push_str(&format!(", max {max_limit}"));
421    }
422    help.push(')');
423    help
424}
425
426fn pagination_limit_value_parser(max_limit: i64) -> ValueParser {
427    ValueParser::new(move |raw: &str| -> Result<i64, String> {
428        let value = raw
429            .parse::<i64>()
430            .map_err(|_| format!("invalid limit value {raw:?}"))?;
431        if max_limit > 0 && value > max_limit {
432            return Err(format!("limit {value} exceeds the maximum of {max_limit}"));
433        }
434        Ok(value)
435    })
436}
437
438/// Rejects a negative `--offset` at parse time — a `clap` usage error — rather
439/// than letting it reach `apply_pagination` in `output/pipeline.rs`, which
440/// already rejects one, but only once the command has otherwise fully run.
441fn pagination_offset_value_parser() -> ValueParser {
442    ValueParser::new(|raw: &str| -> Result<i64, String> {
443        let value = raw
444            .parse::<i64>()
445            .map_err(|_| format!("invalid offset value {raw:?}"))?;
446        if value < 0 {
447            return Err(format!("offset {value} must be non-negative"));
448        }
449        Ok(value)
450    })
451}
452
453/// Resolves the default output format when the user gave no explicit format.
454///
455/// Precedence: `env_override`, then `config_override` (the `[output].format`
456/// key in `config.toml`), then a TTY policy — an interactive terminal gets
457/// human-friendly output, everything else (pipes, files, CI, most agents)
458/// gets machine-readable JSON. Pure so it can be unit-tested without a real
459/// terminal or config file.
460#[must_use]
461pub fn resolve_default_output_format(
462    env_override: Option<&str>,
463    config_override: Option<&str>,
464    is_tty: bool,
465) -> String {
466    // Normalize case (env vars and config values are commonly upper/mixed
467    // case) and ignore blank or unrecognized values, so a stray or miscased
468    // override can't break all command output — only a valid format is
469    // honored, and an invalid one falls through to the next tier.
470    for candidate in [env_override, config_override].into_iter().flatten() {
471        let normalized = candidate.trim().to_ascii_lowercase();
472        if crate::output::is_valid_output_format(&normalized) {
473            return normalized;
474        }
475    }
476    if is_tty { "human" } else { "json" }.to_owned()
477}
478
479/// Sanitizes an app id into an environment-variable prefix: ASCII alphanumerics
480/// are uppercased and every other character becomes `_`, e.g. `godaddy` ->
481/// `GODADDY`, `my-cli` -> `MY_CLI`.
482///
483/// Shared by the framework's app-scoped env vars (for example
484/// [`output_env_var`] and `${PREFIX}_CREDENTIAL_STORE`) so they derive the same
485/// prefix from a given app id.
486#[must_use]
487pub fn app_id_env_prefix(app_id: &str) -> String {
488    app_id
489        .chars()
490        .map(|c| {
491            if c.is_ascii_alphanumeric() {
492                c.to_ascii_uppercase()
493            } else {
494                '_'
495            }
496        })
497        .collect()
498}
499
500/// Derives the per-application output-format override env var from an app id,
501/// e.g. `godaddy` -> `GODADDY_OUTPUT`, `gdx` -> `GDX_OUTPUT`.
502#[must_use]
503pub fn output_env_var(app_id: &str) -> String {
504    format!("{}_OUTPUT", app_id_env_prefix(app_id))
505}
506
507/// Derives the per-application global minimum-stage override env var from an
508/// app id, e.g. `godaddy` -> `GODADDY_MIN_STAGE`, `gdx` -> `GDX_MIN_STAGE`.
509#[must_use]
510pub fn min_stage_env_var(app_id: &str) -> String {
511    format!("{}_MIN_STAGE", app_id_env_prefix(app_id))
512}
513
514/// Computes the default output format for `app_id`, consulting the
515/// `${APP_ID}_OUTPUT` env override, the `[output].format` key in
516/// `config.toml`, and whether stdout is an interactive terminal. Used as the
517/// fallback when no explicit `--output`/`--json`/`--toon`/`--human` is given.
518///
519/// **Blocking**: this loads `config.toml` (see
520/// [`ConfigFile::load`](crate::config::ConfigFile::load)), performing
521/// synchronous filesystem I/O. `Cli` itself never calls this — it resolves
522/// the default from the config already loaded once at `Cli::new` time
523/// instead — but a consumer calling this function directly should avoid
524/// doing so from a hot path or within an async executor without
525/// `spawn_blocking`.
526#[must_use]
527pub fn default_output_format(app_id: &str) -> String {
528    let env = std::env::var(output_env_var(app_id)).ok();
529    let file = crate::config::load(app_id);
530    resolve_default_output_format(
531        env.as_deref(),
532        file.output.format.as_deref(),
533        std::io::stdout().is_terminal(),
534    )
535}
536
537#[must_use]
538/// Extracts framework-global flags from parsed `clap` matches, falling back to
539/// `default_format` when the user gave no explicit output format.
540pub fn global_flags_from_matches(
541    matches: &ArgMatches,
542    default_format: &str,
543    auto_interactive: bool,
544) -> GlobalFlags {
545    let output_format = if matches.get_flag("toon") {
546        "toon".to_owned()
547    } else if matches.get_flag("human") {
548        "human".to_owned()
549    } else if matches.get_flag("json") {
550        "json".to_owned()
551    } else if matches.value_source("output") == Some(clap::parser::ValueSource::CommandLine) {
552        matches
553            .get_one::<String>("output")
554            .cloned()
555            .unwrap_or_else(|| default_format.to_owned())
556    } else {
557        default_format.to_owned()
558    };
559
560    GlobalFlags {
561        output_format,
562        verbose: matches
563            .get_one::<String>("verbose")
564            .cloned()
565            .unwrap_or_default(),
566        dry_run: matches.get_one::<bool>("dry-run").copied().unwrap_or(false),
567        fields: matches
568            .get_one::<String>("fields")
569            .cloned()
570            .unwrap_or_default(),
571        fields_explicit: matches.value_source("fields")
572            == Some(clap::parser::ValueSource::CommandLine),
573        filter: matches
574            .get_one::<String>("filter")
575            .cloned()
576            .unwrap_or_default(),
577        expr: matches
578            .get_one::<String>("expr")
579            .cloned()
580            .unwrap_or_default(),
581        schema: matches.get_one::<bool>("schema").copied().unwrap_or(false),
582        // `--reason` is only registered when an authorizer/auditor/activity
583        // emitter is configured.
584        reason: matches
585            .try_get_one::<String>("reason")
586            .ok()
587            .flatten()
588            .cloned()
589            .unwrap_or_default(),
590        timeout: matches
591            .get_one::<String>("timeout")
592            .cloned()
593            .unwrap_or_else(|| "0s".to_owned()),
594        debug: matches
595            .get_one::<String>("debug")
596            .cloned()
597            .unwrap_or_default(),
598        credential_store: matches
599            .get_one::<crate::config::CredentialStore>("credential-store")
600            .copied(),
601        interactive: if matches.get_flag("non-interactive") {
602            false
603        } else if matches.get_flag("interactive") {
604            true
605        } else if auto_interactive {
606            detect_interactive()
607        } else {
608            false
609        },
610    }
611}
612
613#[must_use]
614/// Extracts output format from raw args.
615///
616/// Recognizes `--output <format>` / `-o <format>` / `--output=<format>`,
617/// plus `--json`, `--toon`, and `--human` as shorthand for their respective
618/// formats. Falls back to `default_format` when none is present.
619pub fn extract_output_format(args: &[impl AsRef<str>], default_format: &str) -> String {
620    for index in 0..args.len() {
621        let arg = args[index].as_ref();
622        if arg == "--output" || arg == "-o" {
623            return args.get(index + 1).map_or_else(
624                || default_format.to_owned(),
625                |value| value.as_ref().to_owned(),
626            );
627        }
628        if let Some(value) = arg.strip_prefix("--output=") {
629            return value.to_owned();
630        }
631        if arg == "--json" {
632            return "json".to_owned();
633        }
634        if arg == "--toon" {
635            return "toon".to_owned();
636        }
637        if arg == "--human" {
638            return "human".to_owned();
639        }
640    }
641    default_format.to_owned()
642}
643
644#[must_use]
645/// Extracts a colon-separated command path from raw args.
646pub fn extract_command_path(
647    args: &[impl AsRef<str>],
648    bool_flags: &BTreeSet<String>,
649    value_flags: &BTreeSet<String>,
650) -> String {
651    let mut parts = Vec::new();
652    let mut index = 1;
653    while index < args.len() {
654        let arg = args[index].as_ref();
655        if arg == "--schema" {
656            index += 1;
657            continue;
658        }
659        if arg.starts_with('-') {
660            if bool_flags.contains(arg) || arg.contains('=') {
661                index += 1;
662                continue;
663            }
664            if value_flags.contains(arg)
665                || (index + 1 < args.len() && !args[index + 1].as_ref().starts_with('-'))
666            {
667                index += 2;
668                continue;
669            }
670            index += 1;
671            continue;
672        }
673        parts.push(arg.to_owned());
674        index += 1;
675    }
676    parts.join(":")
677}
678
679#[must_use]
680/// Reports whether raw args contain a true `--schema` flag.
681pub fn has_true_schema_flag(args: &[impl AsRef<str>]) -> bool {
682    for arg in args {
683        let arg = arg.as_ref();
684        if arg == "--schema" {
685            return true;
686        }
687        if let Some(value) = arg.strip_prefix("--schema=") {
688            return parse_compat_bool(value).unwrap_or(false);
689        }
690    }
691    false
692}
693
694pub(crate) fn compat_bool_value_parser() -> ValueParser {
695    ValueParser::new(parse_compat_bool)
696}
697
698fn parse_compat_bool(raw: &str) -> Result<bool, String> {
699    match raw {
700        "1" | "t" | "T" | "TRUE" | "true" | "True" => Ok(true),
701        "0" | "f" | "F" | "FALSE" | "false" | "False" => Ok(false),
702        _ => Err(format!("invalid boolean value {raw:?}")),
703    }
704}
705
706#[must_use]
707/// Derives flag names that do not consume the following token.
708pub fn derive_bool_flags(command: &Command) -> BTreeSet<String> {
709    let mut flags = BTreeSet::from([
710        "--help".to_owned(),
711        "-h".to_owned(),
712        "--verbose".to_owned(),
713        "--debug".to_owned(),
714    ]);
715    collect_flag_names(command, &mut |arg, name| {
716        if !arg_requires_value(arg) {
717            flags.insert(name);
718        }
719    });
720    flags
721}
722
723#[must_use]
724/// Derives flag names that consume the following token.
725pub fn derive_value_flags(command: &Command) -> BTreeSet<String> {
726    let mut flags = BTreeSet::new();
727    collect_flag_names(command, &mut |arg, name| {
728        if arg_requires_value(arg) {
729            flags.insert(name);
730        }
731    });
732    flags
733}
734
735fn collect_flag_names(command: &Command, visit: &mut impl FnMut(&Arg, String)) {
736    for arg in command.get_arguments() {
737        if arg.is_positional() {
738            continue;
739        }
740        if let Some(long) = arg.get_long() {
741            visit(arg, format!("--{long}"));
742        }
743        if let Some(short) = arg.get_short() {
744            visit(arg, format!("-{short}"));
745        }
746    }
747    for child in command.get_subcommands() {
748        collect_flag_names(child, visit);
749    }
750}
751
752/// Reports whether a `--debug` pattern enables a named component.
753///
754/// The pattern is a comma-separated list of tokens applied left to right, so
755/// later tokens override earlier ones:
756///
757/// - `*` enables every component; `-*` disables every component.
758/// - `name` enables that component; `-name` disables it.
759/// - whitespace around tokens is ignored and matching is case-insensitive.
760///
761/// An empty pattern enables nothing. Tokens that name other components are
762/// ignored for the queried `component`.
763///
764/// # Examples
765///
766/// ```
767/// use cli_engine::debug_component_enabled;
768///
769/// assert!(debug_component_enabled("*", "transport"));
770/// assert!(debug_component_enabled("transport", "transport"));
771/// assert!(!debug_component_enabled("*,-transport", "transport"));
772/// assert!(debug_component_enabled("*,-auth", "transport"));
773/// assert!(!debug_component_enabled("", "transport"));
774/// ```
775#[must_use]
776pub fn debug_component_enabled(pattern: &str, component: &str) -> bool {
777    let component = component.trim().to_ascii_lowercase();
778    // Fail closed: an empty component name is never enabled, not even by `*`.
779    if component.is_empty() {
780        return false;
781    }
782    let mut enabled = false;
783    for raw in pattern.split(',') {
784        let token = raw.trim();
785        if token.is_empty() {
786            continue;
787        }
788        let (negated, name) = token
789            .strip_prefix('-')
790            .map_or((false, token), |rest| (true, rest));
791        let name = name.trim().to_ascii_lowercase();
792        if name == "*" || name == component {
793            enabled = !negated;
794        }
795    }
796    enabled
797}
798
799fn arg_requires_value(arg: &Arg) -> bool {
800    match arg.get_action() {
801        ArgAction::Set | ArgAction::Append => arg
802            .get_num_args()
803            .is_none_or(|range| range.takes_values() && range.min_values() > 0),
804        ArgAction::SetTrue
805        | ArgAction::SetFalse
806        | ArgAction::Count
807        | ArgAction::Help
808        | ArgAction::HelpShort
809        | ArgAction::HelpLong
810        | ArgAction::Version => false,
811        _ => arg
812            .get_num_args()
813            .is_some_and(|range| range.takes_values() && range.min_values() > 0),
814    }
815}
816
817#[cfg(test)]
818mod tests {
819    use clap::Command;
820
821    use super::{
822        debug_component_enabled, min_stage_env_var, output_env_var, register_global_flags,
823        resolve_default_output_format,
824    };
825
826    #[test]
827    fn debug_component_matcher_handles_wildcards_and_negation() {
828        // Empty pattern enables nothing.
829        assert!(!debug_component_enabled("", "transport"));
830        // Wildcard enables everything.
831        assert!(debug_component_enabled("*", "transport"));
832        assert!(debug_component_enabled("*", "auth"));
833        // Bare name enables only that component.
834        assert!(debug_component_enabled("transport", "transport"));
835        assert!(!debug_component_enabled("transport", "auth"));
836        // Negation after a wildcard removes one component but keeps the rest.
837        assert!(!debug_component_enabled("*,-transport", "transport"));
838        assert!(debug_component_enabled("*,-auth", "transport"));
839        // `-*` disables everything; later tokens still win.
840        assert!(!debug_component_enabled("*,-*", "transport"));
841        assert!(debug_component_enabled("-*,transport", "transport"));
842        // Whitespace and case are ignored.
843        assert!(debug_component_enabled(" Transport , -auth ", "transport"));
844        // An empty component fails closed, even against a wildcard.
845        assert!(!debug_component_enabled("*", ""));
846        assert!(!debug_component_enabled("*", "   "));
847    }
848
849    #[test]
850    fn default_output_format_follows_env_override_then_tty() {
851        // TTY policy when no env or config override.
852        assert_eq!(resolve_default_output_format(None, None, true), "human");
853        assert_eq!(resolve_default_output_format(None, None, false), "json");
854        // A valid env override wins over the TTY policy in both directions.
855        assert_eq!(
856            resolve_default_output_format(Some("json"), None, true),
857            "json"
858        );
859        assert_eq!(
860            resolve_default_output_format(Some("human"), None, false),
861            "human"
862        );
863        // Env override is case-insensitive (env vars are commonly upper-cased).
864        assert_eq!(
865            resolve_default_output_format(Some("JSON"), None, true),
866            "json"
867        );
868        assert_eq!(
869            resolve_default_output_format(Some(" Human "), None, false),
870            "human"
871        );
872        // Blank or unrecognized env overrides are ignored (fall back to TTY).
873        assert_eq!(
874            resolve_default_output_format(Some("   "), None, false),
875            "json"
876        );
877        assert_eq!(resolve_default_output_format(Some(""), None, true), "human");
878        assert_eq!(
879            resolve_default_output_format(Some("yaml"), None, false),
880            "json"
881        );
882        assert_eq!(
883            resolve_default_output_format(Some("yaml"), None, true),
884            "human"
885        );
886    }
887
888    #[test]
889    fn default_output_format_config_override_wins_over_tty_but_not_env() {
890        // Config override wins over the TTY policy when there's no env override.
891        assert_eq!(
892            resolve_default_output_format(None, Some("json"), true),
893            "json"
894        );
895        assert_eq!(
896            resolve_default_output_format(None, Some("human"), false),
897            "human"
898        );
899        // Env override still wins over a config override.
900        assert_eq!(
901            resolve_default_output_format(Some("human"), Some("json"), false),
902            "human"
903        );
904        // Blank or unrecognized config overrides are ignored (fall back to TTY).
905        assert_eq!(
906            resolve_default_output_format(None, Some("yaml"), true),
907            "human"
908        );
909        assert_eq!(
910            resolve_default_output_format(None, Some("yaml"), false),
911            "json"
912        );
913    }
914
915    #[test]
916    fn output_env_var_is_derived_from_app_id() {
917        assert_eq!(output_env_var("godaddy"), "GODADDY_OUTPUT");
918        assert_eq!(output_env_var("gdx"), "GDX_OUTPUT");
919        assert_eq!(output_env_var("my-cli"), "MY_CLI_OUTPUT");
920    }
921
922    #[test]
923    fn min_stage_env_var_is_derived_from_app_id() {
924        assert_eq!(min_stage_env_var("godaddy"), "GODADDY_MIN_STAGE");
925        assert_eq!(min_stage_env_var("gdx"), "GDX_MIN_STAGE");
926        assert_eq!(min_stage_env_var("my-cli"), "MY_CLI_MIN_STAGE");
927    }
928
929    #[test]
930    fn short_and_long_help_flags_render_identical_output() {
931        let build = || {
932            register_global_flags(Command::new("testcli"))
933                .subcommand(Command::new("sub").about("A subcommand"))
934        };
935        let help_text = |args: &[&str]| {
936            build()
937                .try_get_matches_from(args)
938                .expect_err("help action short-circuits parsing")
939                .to_string()
940        };
941
942        assert_eq!(
943            help_text(&["testcli", "-h"]),
944            help_text(&["testcli", "--help"])
945        );
946        assert_eq!(
947            help_text(&["testcli", "sub", "-h"]),
948            help_text(&["testcli", "sub", "--help"])
949        );
950    }
951
952    #[test]
953    fn interactivity_mode_from_bool() {
954        use super::InteractivityMode;
955        assert_eq!(
956            InteractivityMode::from(true),
957            InteractivityMode::Interactive
958        );
959        assert_eq!(
960            InteractivityMode::from(false),
961            InteractivityMode::NonInteractive
962        );
963        assert!(InteractivityMode::Interactive.is_interactive());
964        assert!(!InteractivityMode::NonInteractive.is_interactive());
965    }
966
967    #[test]
968    fn interactive_flag_parsing_explicit_interactive() {
969        use super::global_flags_from_matches;
970        let cmd = register_global_flags(Command::new("test"));
971        let matches = cmd
972            .try_get_matches_from(["test", "--interactive"])
973            .expect("should parse");
974        // --interactive works even when auto_interactive is false
975        let flags = global_flags_from_matches(&matches, "json", false);
976        assert!(flags.interactive);
977    }
978
979    #[test]
980    fn interactive_flag_parsing_explicit_non_interactive() {
981        use super::global_flags_from_matches;
982        let cmd = register_global_flags(Command::new("test"));
983        let matches = cmd
984            .try_get_matches_from(["test", "--non-interactive"])
985            .expect("should parse");
986        // --non-interactive wins even when auto_interactive is true
987        let flags = global_flags_from_matches(&matches, "json", true);
988        assert!(!flags.interactive);
989    }
990
991    #[test]
992    fn interactive_defaults_off_without_auto_interactive() {
993        use super::global_flags_from_matches;
994        let cmd = register_global_flags(Command::new("test"));
995        let matches = cmd.try_get_matches_from(["test"]).expect("should parse");
996        // No explicit flag + auto_interactive=false → not interactive
997        let flags = global_flags_from_matches(&matches, "json", false);
998        assert!(!flags.interactive);
999    }
1000
1001    #[test]
1002    fn interactive_flag_conflicts() {
1003        let cmd = register_global_flags(Command::new("test"));
1004        let result = cmd.try_get_matches_from(["test", "--interactive", "--non-interactive"]);
1005        assert!(result.is_err());
1006    }
1007
1008    #[test]
1009    fn detect_interactive_is_consistent_with_tty_state() {
1010        // detect_interactive checks stdin + stderr TTY state.
1011        // In CI (no real TTY), both are typically non-terminals → false.
1012        // Locally in a real terminal, both are terminals → true.
1013        // Either way, it should not panic and should be consistent.
1014        let result = super::detect_interactive();
1015        let stdin_tty = std::io::IsTerminal::is_terminal(&std::io::stdin());
1016        let stderr_tty = std::io::IsTerminal::is_terminal(&std::io::stderr());
1017        assert_eq!(result, stdin_tty && stderr_tty);
1018    }
1019}