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