Skip to main content

apcore_cli/
builtin_group.rs

1//! Built-in Command Group (FE-13).
2//!
3//! Encapsulates visibility resolution and subcommand filtering for the
4//! reserved `apcli` group. Instantiated once by the CLI bootstrap and
5//! attached to the root command.
6//!
7//! Shape mirrors [`crate::exposure::ExposureFilter`]: a private constructor
8//! with named factories and a small set of predicate methods.
9//!
10//! Protocol spec: FE-13 — see `../apcore-cli/docs/features/builtin-group.md`
11//! sections §4.2–§4.7 and §4.14 for authoritative semantics.
12
13use thiserror::Error;
14
15use crate::EXIT_INVALID_INPUT;
16
17// ---------------------------------------------------------------------------
18// Public constants
19// ---------------------------------------------------------------------------
20
21/// Canonical set of apcli subcommand names.
22///
23/// Declarative mirror of the registration table wired in `main.rs`. Used by
24/// the internal list normalizer to warn on unknown entries in include/exclude
25/// lists (spec §7 error table / T-APCLI-25). Keep in sync if subcommands are
26/// added or removed.
27pub const APCLI_SUBCOMMAND_NAMES: &[&str] = &[
28    "list",
29    "describe",
30    "exec",
31    "validate",
32    "init",
33    "health",
34    "usage",
35    "enable",
36    "disable",
37    "reload",
38    "config",
39    "completion",
40    "describe-pipeline",
41];
42
43/// Group names reserved by apcore-cli (checked in `cli.rs`).
44pub const RESERVED_GROUP_NAMES: &[&str] = &["apcli"];
45
46// Valid user-supplied mode strings. Note: `"auto"` is an internal sentinel
47// and is rejected from user-supplied config.
48const VALID_USER_MODES: &[&str] = &["all", "none", "include", "exclude"];
49
50// ---------------------------------------------------------------------------
51// Public config types (spec §4.14)
52// ---------------------------------------------------------------------------
53
54/// User-facing apcli visibility mode.
55///
56/// The `Auto` variant is an internal sentinel meaning "fall through to
57/// auto-detect"; it is never returned from [`ApcliGroup::resolve_visibility`]
58/// and is rejected when supplied via user config.
59#[derive(Clone, Debug, Default, PartialEq, Eq)]
60pub enum ApcliMode {
61    /// Default: auto-detect based on registry-injection (Tier 4).
62    #[default]
63    Auto,
64    /// Show all apcli subcommands.
65    All,
66    /// Hide the entire apcli group.
67    None,
68    /// Whitelist of subcommand names to expose.
69    Include(Vec<String>),
70    /// Blacklist of subcommand names to hide.
71    Exclude(Vec<String>),
72}
73
74/// User-facing apcli config consumed by [`ApcliGroup::from_cli_config`].
75///
76/// Boolean shorthand (handled at the yaml/builder layer) maps to
77/// `mode = All` / `mode = None`.
78#[derive(Clone, Debug, Default)]
79pub struct ApcliConfig {
80    /// Visibility mode.
81    pub mode: ApcliMode,
82    /// When true, the `APCORE_CLI_APCLI` env var (Tier 2) is ignored.
83    pub disable_env: bool,
84}
85
86// ---------------------------------------------------------------------------
87// Errors
88// ---------------------------------------------------------------------------
89
90/// Errors surfaced by the fallible `from_yaml` builder.
91#[derive(Debug, Error, PartialEq, Eq)]
92pub enum ApcliGroupError {
93    /// apcli config was neither a boolean nor a mapping.
94    #[error("Error: apcli config must be a boolean or object; got {0}.")]
95    InvalidShape(String),
96
97    /// `mode` was not a string.
98    #[error(
99        "Error: apcli.mode must be a string; got {0}. \
100         Expected one of all|none|include|exclude."
101    )]
102    ModeNotString(String),
103
104    /// `mode` was a string but not one of the allowed values.
105    #[error(
106        "Error: apcli.mode '{0}' is invalid. \
107         Expected one of all|none|include|exclude."
108    )]
109    ModeInvalid(String),
110}
111
112// ---------------------------------------------------------------------------
113// ApcliGroup
114// ---------------------------------------------------------------------------
115
116/// Visibility configuration for the built-in `apcli` command group.
117///
118/// Instantiated via [`ApcliGroup::from_cli_config`] (Tier 1) or
119/// [`ApcliGroup::from_yaml`] (Tier 3). The constructor is private to
120/// preserve the Tier-1-vs-Tier-3 flag distinction.
121#[derive(Debug)]
122pub struct ApcliGroup {
123    mode: InternalMode,
124    include: Vec<String>,
125    exclude: Vec<String>,
126    disable_env: bool,
127    registry_injected: bool,
128    from_cli_config: bool,
129}
130
131/// Internal flat mode sentinel. Unlike [`ApcliMode`], this does not carry
132/// the list payload — `include` / `exclude` vectors live on the struct.
133#[derive(Clone, Copy, Debug, PartialEq, Eq)]
134enum InternalMode {
135    Auto,
136    All,
137    None,
138    Include,
139    Exclude,
140}
141
142impl InternalMode {
143    fn as_str(self) -> &'static str {
144        match self {
145            InternalMode::Auto => "auto",
146            InternalMode::All => "all",
147            InternalMode::None => "none",
148            InternalMode::Include => "include",
149            InternalMode::Exclude => "exclude",
150        }
151    }
152}
153
154impl ApcliGroup {
155    // -------------------------------------------------------------------------
156    // Factories
157    // -------------------------------------------------------------------------
158
159    /// Tier 1 constructor — config came from a programmatic embedder
160    /// (i.e. an `ApcliConfig` value passed to a future embedding API).
161    ///
162    /// A non-auto mode from this tier wins over env var and yaml. Because
163    /// `ApcliConfig` is strongly typed, no validation is needed here.
164    pub fn from_cli_config(config: Option<ApcliConfig>, registry_injected: bool) -> Self {
165        let (mode, include, exclude, disable_env) = match config {
166            None => (InternalMode::Auto, Vec::new(), Vec::new(), false),
167            Some(cfg) => {
168                let disable_env = cfg.disable_env;
169                match cfg.mode {
170                    ApcliMode::Auto => (InternalMode::Auto, Vec::new(), Vec::new(), disable_env),
171                    ApcliMode::All => (InternalMode::All, Vec::new(), Vec::new(), disable_env),
172                    ApcliMode::None => (InternalMode::None, Vec::new(), Vec::new(), disable_env),
173                    ApcliMode::Include(list) => {
174                        Self::warn_unknown_entries(&list, "include");
175                        (InternalMode::Include, list, Vec::new(), disable_env)
176                    }
177                    ApcliMode::Exclude(list) => {
178                        Self::warn_unknown_entries(&list, "exclude");
179                        (InternalMode::Exclude, Vec::new(), list, disable_env)
180                    }
181                }
182            }
183        };
184
185        Self {
186            mode,
187            include,
188            exclude,
189            disable_env,
190            registry_injected,
191            from_cli_config: true,
192        }
193    }
194
195    /// Tier 3 constructor — config came from `apcore.yaml`.
196    ///
197    /// Env var (Tier 2) may override the yaml-supplied mode unless
198    /// `disable_env` is true. On validation error, prints a message to stderr
199    /// and calls [`std::process::exit`] with [`EXIT_INVALID_INPUT`]; the
200    /// fallible variant [`ApcliGroup::try_from_yaml`] is available for tests.
201    pub fn from_yaml(yaml_value: Option<serde_yaml::Value>, registry_injected: bool) -> Self {
202        match Self::try_from_yaml(yaml_value, registry_injected) {
203            Ok(group) => group,
204            Err(e) => {
205                eprintln!("{e}");
206                std::process::exit(EXIT_INVALID_INPUT);
207            }
208        }
209    }
210
211    /// Fallible sibling of [`ApcliGroup::from_yaml`]. Used by tests; the
212    /// production wrapper prints the error and exits.
213    pub fn try_from_yaml(
214        yaml_value: Option<serde_yaml::Value>,
215        registry_injected: bool,
216    ) -> Result<Self, ApcliGroupError> {
217        use serde_yaml::Value;
218
219        // Missing / null → auto.
220        let value = match yaml_value {
221            None => return Ok(Self::auto(registry_injected, false)),
222            Some(v) => v,
223        };
224
225        match value {
226            Value::Null => Ok(Self::auto(registry_injected, false)),
227            Value::Bool(true) => Ok(Self {
228                mode: InternalMode::All,
229                include: Vec::new(),
230                exclude: Vec::new(),
231                disable_env: false,
232                registry_injected,
233                from_cli_config: false,
234            }),
235            Value::Bool(false) => Ok(Self {
236                mode: InternalMode::None,
237                include: Vec::new(),
238                exclude: Vec::new(),
239                disable_env: false,
240                registry_injected,
241                from_cli_config: false,
242            }),
243            Value::Mapping(map) => Self::build_from_mapping(map, registry_injected),
244            Value::Sequence(_) => Err(ApcliGroupError::InvalidShape("array".to_string())),
245            Value::String(_) => Err(ApcliGroupError::InvalidShape("string".to_string())),
246            Value::Number(_) => Err(ApcliGroupError::InvalidShape("number".to_string())),
247            Value::Tagged(_) => Err(ApcliGroupError::InvalidShape("tagged".to_string())),
248        }
249    }
250
251    fn auto(registry_injected: bool, from_cli_config: bool) -> Self {
252        Self {
253            mode: InternalMode::Auto,
254            include: Vec::new(),
255            exclude: Vec::new(),
256            disable_env: false,
257            registry_injected,
258            from_cli_config,
259        }
260    }
261
262    fn build_from_mapping(
263        map: serde_yaml::Mapping,
264        registry_injected: bool,
265    ) -> Result<Self, ApcliGroupError> {
266        use serde_yaml::Value;
267
268        // Look up by string key. Skip (with warning) keys that are not
269        // scalar strings — uncommon in yaml but technically legal.
270        let get = |name: &str| -> Option<Value> {
271            for (k, v) in &map {
272                match k {
273                    Value::String(s) if s == name => return Some(v.clone()),
274                    _ => continue,
275                }
276            }
277            None
278        };
279
280        // Warn once if there are any non-string keys.
281        for (k, _) in &map {
282            if !matches!(k, Value::String(_)) {
283                tracing::warn!("apcli config has a non-string key; ignoring.");
284                break;
285            }
286        }
287
288        // Mode. Missing/null → Auto. Non-string or unknown string → error.
289        let mode = match get("mode") {
290            None | Some(Value::Null) => InternalMode::Auto,
291            Some(Value::String(s)) => {
292                if !VALID_USER_MODES.contains(&s.as_str()) {
293                    return Err(ApcliGroupError::ModeInvalid(s));
294                }
295                match s.as_str() {
296                    "all" => InternalMode::All,
297                    "none" => InternalMode::None,
298                    "include" => InternalMode::Include,
299                    "exclude" => InternalMode::Exclude,
300                    _ => unreachable!("VALID_USER_MODES check above"),
301                }
302            }
303            Some(other) => {
304                return Err(ApcliGroupError::ModeNotString(
305                    yaml_type_name(&other).into(),
306                ));
307            }
308        };
309
310        let include = Self::normalize_list(get("include"), "include");
311        let exclude = Self::normalize_list(get("exclude"), "exclude");
312
313        // disable_env accepts both snake_case and camelCase.
314        let raw_disable_env = get("disable_env").or_else(|| get("disableEnv"));
315        let disable_env = match raw_disable_env {
316            None | Some(Value::Null) => false,
317            Some(Value::Bool(b)) => b,
318            Some(other) => {
319                tracing::warn!(
320                    "apcli.disable_env must be boolean; got {}. Treating as false.",
321                    yaml_type_name(&other)
322                );
323                false
324            }
325        };
326
327        Ok(Self {
328            mode,
329            include,
330            exclude,
331            disable_env,
332            registry_injected,
333            from_cli_config: false,
334        })
335    }
336
337    /// Normalize a yaml include/exclude list. Non-array → warn + empty.
338    /// Unknown-but-well-formed entries emit a warning but are retained for
339    /// forward compatibility (spec §7 / T-APCLI-25).
340    fn normalize_list(raw: Option<serde_yaml::Value>, label: &str) -> Vec<String> {
341        use serde_yaml::Value;
342        let raw = match raw {
343            None | Some(Value::Null) => return Vec::new(),
344            Some(v) => v,
345        };
346        let seq = match raw {
347            Value::Sequence(s) => s,
348            other => {
349                tracing::warn!(
350                    "apcli.{} must be a list; got {}. Ignoring.",
351                    label,
352                    yaml_type_name(&other)
353                );
354                return Vec::new();
355            }
356        };
357        let mut out = Vec::with_capacity(seq.len());
358        for entry in seq {
359            match entry {
360                Value::String(s) if !s.is_empty() => {
361                    if !APCLI_SUBCOMMAND_NAMES.contains(&s.as_str()) {
362                        tracing::warn!(
363                            "Unknown apcli subcommand '{}' in {} list -- ignoring.",
364                            s,
365                            label
366                        );
367                    }
368                    out.push(s);
369                }
370                _ => {
371                    tracing::warn!("apcli.{} contains non-string entry; skipping.", label);
372                }
373            }
374        }
375        out
376    }
377
378    /// Emit the unknown-subcommand warnings for a strongly-typed list from
379    /// `ApcliConfig` (Tier 1). Mirrors the yaml-path warnings so behaviour
380    /// is identical regardless of where the config originated.
381    fn warn_unknown_entries(list: &[String], label: &str) {
382        for entry in list {
383            if !APCLI_SUBCOMMAND_NAMES.contains(&entry.as_str()) {
384                tracing::warn!(
385                    "Unknown apcli subcommand '{}' in {} list -- ignoring.",
386                    entry,
387                    label
388                );
389            }
390        }
391    }
392
393    // -------------------------------------------------------------------------
394    // Public API
395    // -------------------------------------------------------------------------
396
397    /// Resolve effective visibility after applying the four-tier precedence.
398    ///
399    /// Returns one of `"all" | "none" | "include" | "exclude"` — never
400    /// `"auto"`. Tier order (spec §4.4):
401    ///
402    /// 1. `from_cli_config` with a non-auto mode wins outright.
403    /// 2. `APCORE_CLI_APCLI` env var (unless sealed by `disable_env`).
404    /// 3. yaml non-auto mode.
405    /// 4. Auto-detect: `registry_injected ? "none" : "all"`.
406    pub fn resolve_visibility(&self) -> &'static str {
407        // Tier 1 — programmatic embedder config (non-auto).
408        if self.from_cli_config && self.mode != InternalMode::Auto {
409            return self.mode.as_str();
410        }
411
412        // Tier 2 — env var (unless sealed).
413        if !self.disable_env {
414            if let Some(env_mode) = Self::parse_env(std::env::var("APCORE_CLI_APCLI").ok()) {
415                return env_mode;
416            }
417        }
418
419        // Tier 3 — yaml non-auto.
420        if self.mode != InternalMode::Auto {
421            return self.mode.as_str();
422        }
423
424        // Tier 4 — auto-detect.
425        if self.registry_injected {
426            "none"
427        } else {
428            "all"
429        }
430    }
431
432    /// Return true iff `subcommand` passes the include/exclude filter.
433    ///
434    /// Callers MUST first check [`ApcliGroup::resolve_visibility`] — this
435    /// method panics if called under modes `"all"` or `"none"` (caller bug
436    /// per spec §4.6).
437    pub fn is_subcommand_included(&self, subcommand: &str) -> bool {
438        match self.resolve_visibility() {
439            "include" => self.include.iter().any(|s| s == subcommand),
440            "exclude" => !self.exclude.iter().any(|s| s == subcommand),
441            other => unreachable!(
442                "is_subcommand_included called under mode '{other}'; caller should bypass."
443            ),
444        }
445    }
446
447    /// True iff the `apcli` group itself should appear in root `--help`.
448    pub fn is_group_visible(&self) -> bool {
449        self.resolve_visibility() != "none"
450    }
451
452    /// Enumerate the effective include list. Empty unless resolved mode is
453    /// `"include"`.
454    pub fn include(&self) -> &[String] {
455        &self.include
456    }
457
458    /// Enumerate the effective exclude list. Empty unless resolved mode is
459    /// `"exclude"`.
460    pub fn exclude(&self) -> &[String] {
461        &self.exclude
462    }
463
464    /// True iff Tier 2 env-var lookup is sealed.
465    pub fn disable_env(&self) -> bool {
466        self.disable_env
467    }
468
469    // -------------------------------------------------------------------------
470    // Env parser (Tier 2) — co-located per spec §4.4
471    // -------------------------------------------------------------------------
472
473    /// Parse `APCORE_CLI_APCLI`. Case-insensitive.
474    ///
475    /// - `show` / `1` / `true` → `Some("all")`
476    /// - `hide` / `0` / `false` → `Some("none")`
477    /// - Empty / unset → `None`
478    /// - Anything else → warn and return `None`
479    fn parse_env(raw: Option<String>) -> Option<&'static str> {
480        let raw = raw?;
481        if raw.is_empty() {
482            return None;
483        }
484        let normalized = raw.to_lowercase();
485        match normalized.as_str() {
486            "show" | "1" | "true" => Some("all"),
487            "hide" | "0" | "false" => Some("none"),
488            _ => {
489                tracing::warn!(
490                    "Unknown APCORE_CLI_APCLI value '{}', ignoring. \
491                     Expected: show, hide, 1, 0, true, false.",
492                    raw
493                );
494                None
495            }
496        }
497    }
498}
499
500// ---------------------------------------------------------------------------
501// Helpers
502// ---------------------------------------------------------------------------
503
504fn yaml_type_name(v: &serde_yaml::Value) -> &'static str {
505    use serde_yaml::Value;
506    match v {
507        Value::Null => "null",
508        Value::Bool(_) => "boolean",
509        Value::Number(_) => "number",
510        Value::String(_) => "string",
511        Value::Sequence(_) => "array",
512        Value::Mapping(_) => "object",
513        Value::Tagged(_) => "tagged",
514    }
515}
516
517// ---------------------------------------------------------------------------
518// Tests
519// ---------------------------------------------------------------------------
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    use serde_yaml::Value;
525    use std::sync::Mutex;
526
527    /// Serializes tests that set/unset `APCORE_CLI_APCLI`. Same pattern as
528    /// the `resolve_log_level` tests in `main.rs`.
529    static ENV_MUTEX: Mutex<()> = Mutex::new(());
530
531    fn clear_env() {
532        // SAFETY: test-only env manipulation, serialized via ENV_MUTEX.
533        unsafe {
534            std::env::remove_var("APCORE_CLI_APCLI");
535        }
536    }
537
538    fn set_env(val: &str) {
539        // SAFETY: test-only env manipulation, serialized via ENV_MUTEX.
540        unsafe {
541            std::env::set_var("APCORE_CLI_APCLI", val);
542        }
543    }
544
545    // ----- Constants -----
546
547    #[test]
548    fn apcli_subcommand_names_has_13_entries() {
549        assert_eq!(APCLI_SUBCOMMAND_NAMES.len(), 13);
550    }
551
552    #[test]
553    fn apcli_subcommand_names_contents() {
554        for expected in &[
555            "list",
556            "describe",
557            "exec",
558            "validate",
559            "init",
560            "health",
561            "usage",
562            "enable",
563            "disable",
564            "reload",
565            "config",
566            "completion",
567            "describe-pipeline",
568        ] {
569            assert!(
570                APCLI_SUBCOMMAND_NAMES.contains(expected),
571                "missing: {expected}"
572            );
573        }
574    }
575
576    #[test]
577    fn reserved_group_names_contents() {
578        assert_eq!(RESERVED_GROUP_NAMES, &["apcli"]);
579    }
580
581    // ----- Tier 1: from_cli_config -----
582
583    #[test]
584    fn from_cli_config_all_wins_in_embedded() {
585        let _g = ENV_MUTEX.lock().unwrap();
586        clear_env();
587        let group = ApcliGroup::from_cli_config(
588            Some(ApcliConfig {
589                mode: ApcliMode::All,
590                disable_env: false,
591            }),
592            /*registry_injected*/ true,
593        );
594        assert_eq!(group.resolve_visibility(), "all");
595    }
596
597    #[test]
598    fn from_cli_config_none_default_standalone_autodetect_all() {
599        let _g = ENV_MUTEX.lock().unwrap();
600        clear_env();
601        let group = ApcliGroup::from_cli_config(None, /*registry_injected*/ false);
602        assert_eq!(group.resolve_visibility(), "all");
603    }
604
605    #[test]
606    fn from_cli_config_none_default_embedded_autodetect_none() {
607        let _g = ENV_MUTEX.lock().unwrap();
608        clear_env();
609        let group = ApcliGroup::from_cli_config(None, /*registry_injected*/ true);
610        assert_eq!(group.resolve_visibility(), "none");
611    }
612
613    #[test]
614    fn from_cli_config_none_mode_beats_env_show() {
615        // Tier 1 (None) > Tier 2 (env=show). Even without disable_env, an
616        // explicit Tier-1 (programmatic) mode wins — defence in depth.
617        let _g = ENV_MUTEX.lock().unwrap();
618        set_env("show");
619        let group = ApcliGroup::from_cli_config(
620            Some(ApcliConfig {
621                mode: ApcliMode::None,
622                disable_env: false,
623            }),
624            /*registry_injected*/ false,
625        );
626        assert_eq!(group.resolve_visibility(), "none");
627        clear_env();
628    }
629
630    // ----- Tier 3: from_yaml (bool shorthand) -----
631
632    #[test]
633    fn from_yaml_bool_true_embedded_all() {
634        let _g = ENV_MUTEX.lock().unwrap();
635        clear_env();
636        let group = ApcliGroup::from_yaml(Some(Value::Bool(true)), /*registry_injected*/ true);
637        assert_eq!(group.resolve_visibility(), "all");
638    }
639
640    #[test]
641    fn from_yaml_bool_false_standalone_none() {
642        let _g = ENV_MUTEX.lock().unwrap();
643        clear_env();
644        let group =
645            ApcliGroup::from_yaml(Some(Value::Bool(false)), /*registry_injected*/ false);
646        assert_eq!(group.resolve_visibility(), "none");
647    }
648
649    #[test]
650    fn from_yaml_null_value_auto() {
651        let _g = ENV_MUTEX.lock().unwrap();
652        clear_env();
653        let group = ApcliGroup::from_yaml(Some(Value::Null), /*registry_injected*/ false);
654        assert_eq!(group.resolve_visibility(), "all");
655    }
656
657    #[test]
658    fn from_yaml_none_auto() {
659        let _g = ENV_MUTEX.lock().unwrap();
660        clear_env();
661        let group = ApcliGroup::from_yaml(None, /*registry_injected*/ true);
662        assert_eq!(group.resolve_visibility(), "none");
663    }
664
665    // ----- Tier 2: env var overrides -----
666
667    #[test]
668    fn from_yaml_null_env_show_all() {
669        let _g = ENV_MUTEX.lock().unwrap();
670        set_env("show");
671        let group = ApcliGroup::from_yaml(None, /*registry_injected*/ true);
672        assert_eq!(group.resolve_visibility(), "all");
673        clear_env();
674    }
675
676    #[test]
677    fn from_yaml_null_env_hide_none() {
678        let _g = ENV_MUTEX.lock().unwrap();
679        set_env("hide");
680        let group = ApcliGroup::from_yaml(None, /*registry_injected*/ false);
681        assert_eq!(group.resolve_visibility(), "none");
682        clear_env();
683    }
684
685    #[test]
686    fn from_yaml_mode_none_env_show_env_wins() {
687        // Tier 2 > Tier 3 when disable_env is false.
688        let _g = ENV_MUTEX.lock().unwrap();
689        set_env("show");
690        let yaml: Value = serde_yaml::from_str("mode: none").unwrap();
691        let group = ApcliGroup::from_yaml(Some(yaml), /*registry_injected*/ true);
692        assert_eq!(group.resolve_visibility(), "all");
693        clear_env();
694    }
695
696    #[test]
697    fn from_yaml_mode_none_disable_env_env_show_yaml_wins() {
698        // disable_env: true seals Tier 2 — yaml mode:none wins.
699        let _g = ENV_MUTEX.lock().unwrap();
700        set_env("show");
701        let yaml: Value = serde_yaml::from_str("mode: none\ndisable_env: true").unwrap();
702        let group = ApcliGroup::from_yaml(Some(yaml), /*registry_injected*/ true);
703        assert_eq!(group.resolve_visibility(), "none");
704        clear_env();
705    }
706
707    #[test]
708    fn from_yaml_disable_env_camel_case_also_accepted() {
709        let _g = ENV_MUTEX.lock().unwrap();
710        set_env("show");
711        let yaml: Value = serde_yaml::from_str("mode: none\ndisableEnv: true").unwrap();
712        let group = ApcliGroup::from_yaml(Some(yaml), /*registry_injected*/ true);
713        assert_eq!(group.resolve_visibility(), "none");
714        clear_env();
715    }
716
717    #[test]
718    fn env_case_insensitive_show() {
719        let _g = ENV_MUTEX.lock().unwrap();
720        for raw in &["SHOW", "Show", "sHoW"] {
721            set_env(raw);
722            let group = ApcliGroup::from_yaml(None, true);
723            assert_eq!(group.resolve_visibility(), "all", "raw={raw}");
724        }
725        clear_env();
726    }
727
728    #[test]
729    fn env_case_insensitive_true_hide_false_numeric() {
730        let _g = ENV_MUTEX.lock().unwrap();
731        for (raw, expected) in &[
732            ("True", "all"),
733            ("TRUE", "all"),
734            ("HIDE", "none"),
735            ("False", "none"),
736            ("1", "all"),
737            ("0", "none"),
738        ] {
739            set_env(raw);
740            let group = ApcliGroup::from_yaml(None, true);
741            assert_eq!(group.resolve_visibility(), *expected, "raw={raw}");
742        }
743        clear_env();
744    }
745
746    #[test]
747    fn env_unknown_value_falls_through() {
748        // parse_env with a bogus value returns None (after warning) — so
749        // Tier 4 auto-detect takes over.
750        let _g = ENV_MUTEX.lock().unwrap();
751        set_env("bogus");
752        let group = ApcliGroup::from_yaml(None, /*registry_injected*/ true);
753        assert_eq!(group.resolve_visibility(), "none");
754        clear_env();
755    }
756
757    #[test]
758    fn env_empty_string_treated_as_unset() {
759        let _g = ENV_MUTEX.lock().unwrap();
760        set_env("");
761        let group = ApcliGroup::from_yaml(None, /*registry_injected*/ false);
762        assert_eq!(group.resolve_visibility(), "all");
763        clear_env();
764    }
765
766    // ----- Include / Exclude semantics -----
767
768    #[test]
769    fn include_mode_filters_correctly() {
770        let _g = ENV_MUTEX.lock().unwrap();
771        clear_env();
772        let yaml: Value =
773            serde_yaml::from_str("mode: include\ninclude:\n  - list\n  - describe").unwrap();
774        let group = ApcliGroup::from_yaml(Some(yaml), /*registry_injected*/ true);
775        assert_eq!(group.resolve_visibility(), "include");
776        assert!(group.is_subcommand_included("list"));
777        assert!(group.is_subcommand_included("describe"));
778        assert!(!group.is_subcommand_included("init"));
779        assert!(!group.is_subcommand_included("exec"));
780        assert_eq!(group.include(), &["list", "describe"]);
781        assert!(group.exclude().is_empty());
782    }
783
784    #[test]
785    fn exclude_mode_filters_correctly() {
786        let _g = ENV_MUTEX.lock().unwrap();
787        clear_env();
788        let yaml: Value = serde_yaml::from_str("mode: exclude\nexclude:\n  - init").unwrap();
789        let group = ApcliGroup::from_yaml(Some(yaml), /*registry_injected*/ true);
790        assert_eq!(group.resolve_visibility(), "exclude");
791        assert!(!group.is_subcommand_included("init"));
792        assert!(group.is_subcommand_included("list"));
793        assert!(group.is_subcommand_included("describe"));
794        assert!(group.include().is_empty());
795        assert_eq!(group.exclude(), &["init"]);
796    }
797
798    #[test]
799    fn from_cli_config_include_variant_filters_correctly() {
800        let _g = ENV_MUTEX.lock().unwrap();
801        clear_env();
802        let group = ApcliGroup::from_cli_config(
803            Some(ApcliConfig {
804                mode: ApcliMode::Include(vec!["list".into(), "describe".into()]),
805                disable_env: false,
806            }),
807            /*registry_injected*/ true,
808        );
809        assert_eq!(group.resolve_visibility(), "include");
810        assert!(group.is_subcommand_included("list"));
811        assert!(!group.is_subcommand_included("init"));
812    }
813
814    // ----- Group visibility -----
815
816    #[test]
817    fn is_group_visible_false_only_for_none_mode() {
818        let _g = ENV_MUTEX.lock().unwrap();
819        clear_env();
820        let hidden = ApcliGroup::from_cli_config(
821            Some(ApcliConfig {
822                mode: ApcliMode::None,
823                disable_env: false,
824            }),
825            true,
826        );
827        assert!(!hidden.is_group_visible());
828
829        let shown = ApcliGroup::from_cli_config(
830            Some(ApcliConfig {
831                mode: ApcliMode::All,
832                disable_env: false,
833            }),
834            true,
835        );
836        assert!(shown.is_group_visible());
837
838        let include = ApcliGroup::from_cli_config(
839            Some(ApcliConfig {
840                mode: ApcliMode::Include(vec!["list".into()]),
841                disable_env: false,
842            }),
843            true,
844        );
845        assert!(include.is_group_visible());
846    }
847
848    // ----- Validation errors -----
849
850    #[test]
851    fn try_from_yaml_rejects_mode_auto() {
852        // Even though Auto is the internal default, user-supplied "auto"
853        // is rejected per spec §4.2.
854        let yaml: Value = serde_yaml::from_str("mode: auto").unwrap();
855        let err = ApcliGroup::try_from_yaml(Some(yaml), true).unwrap_err();
856        assert!(matches!(err, ApcliGroupError::ModeInvalid(ref s) if s == "auto"));
857    }
858
859    #[test]
860    fn try_from_yaml_rejects_unknown_mode() {
861        let yaml: Value = serde_yaml::from_str("mode: whitelist").unwrap();
862        let err = ApcliGroup::try_from_yaml(Some(yaml), true).unwrap_err();
863        assert!(matches!(err, ApcliGroupError::ModeInvalid(_)));
864    }
865
866    #[test]
867    fn try_from_yaml_rejects_non_string_mode() {
868        let yaml: Value = serde_yaml::from_str("mode: 42").unwrap();
869        let err = ApcliGroup::try_from_yaml(Some(yaml), true).unwrap_err();
870        assert!(matches!(err, ApcliGroupError::ModeNotString(_)));
871    }
872
873    #[test]
874    fn try_from_yaml_rejects_array_shape() {
875        let yaml: Value = serde_yaml::from_str("- a\n- b").unwrap();
876        let err = ApcliGroup::try_from_yaml(Some(yaml), true).unwrap_err();
877        assert!(matches!(err, ApcliGroupError::InvalidShape(ref s) if s == "array"));
878    }
879
880    #[test]
881    fn try_from_yaml_rejects_string_shape() {
882        let yaml = Value::String("oops".into());
883        let err = ApcliGroup::try_from_yaml(Some(yaml), true).unwrap_err();
884        assert!(matches!(err, ApcliGroupError::InvalidShape(ref s) if s == "string"));
885    }
886
887    // ----- Tier-3 object form with extras -----
888
889    #[test]
890    fn try_from_yaml_object_without_mode_is_auto() {
891        let _g = ENV_MUTEX.lock().unwrap();
892        clear_env();
893        let yaml: Value = serde_yaml::from_str("disable_env: true").unwrap();
894        let group = ApcliGroup::try_from_yaml(Some(yaml), /*registry_injected*/ false).unwrap();
895        // Tier 3 falls to Tier 4 auto-detect → standalone → "all".
896        assert_eq!(group.resolve_visibility(), "all");
897        assert!(group.disable_env());
898    }
899
900    #[test]
901    fn try_from_yaml_include_non_array_warns_and_empty() {
902        let yaml: Value = serde_yaml::from_str("mode: include\ninclude: not-a-list").unwrap();
903        let group = ApcliGroup::try_from_yaml(Some(yaml), true).unwrap();
904        assert_eq!(group.resolve_visibility(), "include");
905        assert!(group.include().is_empty());
906    }
907
908    #[test]
909    fn try_from_yaml_unknown_include_entry_retained() {
910        let yaml: Value =
911            serde_yaml::from_str("mode: include\ninclude:\n  - list\n  - bogus").unwrap();
912        let group = ApcliGroup::try_from_yaml(Some(yaml), true).unwrap();
913        // Unknown entry is retained for forward-compat.
914        assert_eq!(group.include(), &["list", "bogus"]);
915    }
916
917    #[test]
918    fn try_from_yaml_disable_env_non_bool_treated_as_false() {
919        let yaml: Value = serde_yaml::from_str("mode: none\ndisable_env: \"yes-please\"").unwrap();
920        let group = ApcliGroup::try_from_yaml(Some(yaml), true).unwrap();
921        assert!(!group.disable_env());
922    }
923}